git » sdk » commit a7e3c2a

Get and use actual member list

author Stephen Paul Weber
2026-05-22 01:13:01 UTC
committer Stephen Paul Weber
2026-05-25 02:56:00 UTC
parent 63c2285baa577ec75aff10920685b79a62f430fc

Get and use actual member list

Do not infer members only from presence, but also keep around for later
and get from MAV etc. Load from persistence on demand instead of keeping
all data for ever chat's participants in memory at all times.

Makefile +1 -1
borogove/AvailableChatIterator.hx +50 -30
borogove/Caps.hx +41 -26
borogove/CapsRepo.hx +12 -4
borogove/Chat.hx +372 -271
borogove/ChatMessage.hx +9 -1
borogove/Client.hx +72 -41
borogove/Html.hx +2 -2
borogove/{Participant.hx => Member.hx} +36 -13
borogove/MemberUpdate.hx +93 -0
borogove/Message.hx +3 -1
borogove/Persistence.hx +55 -0
borogove/Presence.hx +30 -0
borogove/Role.hx +2 -2
borogove/Util.hx +4 -0
borogove/persistence/Dummy.hx +25 -0
borogove/persistence/IDB.js +210 -14
borogove/persistence/Sqlite.hx +37 -4
npm/index.ts +1 -1
test/TestAll.hx +2 -1
test/TestCaps.hx +141 -1
test/TestCapsRepo.hx +34 -0
test/TestChat.hx +83 -50
test/TestClient.hx +118 -0
test/TestHtml.hx +3 -3
test/{TestParticipant.hx => TestMember.hx} +3 -3
test/TestMemberUpdate.hx +114 -0
test/TestSortId.hx +1 -3
test/idb.spec.ts +558 -4

diff --git a/Makefile b/Makefile
index 08b3d92..4406508 100644
--- a/Makefile
+++ b/Makefile
@@ -62,7 +62,7 @@ npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogov
 	cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d persistence-browser.ts
 
 playwright/.cache/borogove.js: npm
-	cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate }" --outfile=../$@
+	cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@
 
 playwright/.cache/sqlite-wasm.js: npm
 	cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" --outfile=../$@
diff --git a/borogove/AvailableChatIterator.hx b/borogove/AvailableChatIterator.hx
index 9860e93..b4b5571 100644
--- a/borogove/AvailableChatIterator.hx
+++ b/borogove/AvailableChatIterator.hx
@@ -8,6 +8,7 @@ import borogove.queries.JabberIqGatewayGet;
 import borogove.Util;
 using Lambda;
 using StringTools;
+using borogove.Util;
 
 #if cpp
 import HaxeCBridge;
@@ -19,6 +20,7 @@ import HaxeCBridge;
 @:build(HaxeSwiftBridge.expose())
 @:HaxeSwiftBridge.asyncSequence(AvailableChat)
 #end
+@:nullSafety(StrictThreaded)
 class AvailableChatIterator {
 	/**
 		The query that this iterator is returning results for
@@ -27,8 +29,9 @@ class AvailableChatIterator {
 	private final query: String;
 	private final client: Client;
 	private final persistence: Persistence;
-	private var results: Array<Promise<Null<AvailableChat>>> = [];
-	private var dedup: Map<String, Bool> = [];
+	private var results: Array<Promise<Array<AvailableChat>>> = [];
+	private var head: Array<AvailableChat> = [];
+	private var dedup: Map<String, Bool> = new Map();
 
 	@:allow(borogove)
 	private function new(q: String, client: Client, persistence: Persistence) {
@@ -65,45 +68,50 @@ class AvailableChatIterator {
 				final jid = JID.parse(uriDecode(parts[0]));
 				if (jid.isValid()) return check(jid);
 
-				return Promise.resolve(null);
+				return Promise.resolve([]);
 			}));
 		}
 
 		final lowerQ = query.toLowerCase();
+		final laterResults = [];
 		for (chat in client.chats) {
 			if (chat.chatId != client.accountId()) {
 				if (chat.chatId.contains(lowerQ) || chat.getDisplayName().toLowerCase().contains(lowerQ) || Util.existsFast(chat.getTags(), t -> t.toLowerCase() == lowerQ)) {
-					final channel = Util.downcast(chat, Channel);
-					results.push(Promise.resolve(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], [], []) : channel.disco)));
-				}
-
-				for (p in chat.getParticipants()) {
-					final details = chat.getParticipantDetails(p);
-					if (details.chat != null && (details.chat.chatId.contains(lowerQ) || (details.chat.displayName ?? "").toLowerCase().contains(lowerQ))) {
-						results.push(Promise.resolve(details.chat));
+					final caps = chat.getCaps();
+					final available = Promise.resolve([new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, caps.hasNext() ? caps.next().value : CapsRepo.empty)]);
+					if (chat.isTrusted()) {
+						results.push(available);
+					} else {
+						laterResults.push(available);
 					}
 				}
+
+				laterResults.push(chat.members().then(members ->
+					members.map(member -> member.chat).filterOutNulls(chat ->
+						chat != null && (chat.chatId.contains(lowerQ) || (chat.displayName ?? "").toLowerCase().contains(lowerQ))
+					)
+				));
 			}
 			if (chat.isTrusted()) {
-				final resources:Map<String, Bool> = [];
-				for (resource in Caps.withIdentity(chat.getCaps(), "gateway", null)) {
+				final resources:StringMapNullableKey<Caps> = new StringMapNullableKey();
+				for (resource => caps in Caps.withIdentity(chat.getCaps(), "gateway", null)) {
 					// Sometimes gateway items also have id "gateway" for whatever reason
-					final identities = chat.getResourceCaps(resource)?.identities ?? [];
+					final identities = caps.identities;
 					if (
 						(chat.chatId.indexOf("@") < 0 || identities.find(i -> i.category == "conference") == null) &&
 						identities.find(i -> i.category == "client") == null
 					) {
-						resources[resource] = true;
+						resources[resource] = caps;
 					}
 				}
 				/* Gajim advertises this, so just go with identity instead
-				for (resource in Caps.withFeature(chat.getCaps(), "jabber:iq:gateway")) {
+				for (resource => caps in Caps.withFeature(chat.getCaps(), "jabber:iq:gateway")) {
 					resources[resource] = true;
 				}*/
-				if (!client.sendAvailable && JID.parse(chat.chatId).isDomain()) {
-					resources[null] = true;
+				if (!resources.exists(null) && !client.sendAvailable && JID.parse(chat.chatId).isDomain()) {
+					resources[null] = CapsRepo.empty;
 				}
-				for (resource in resources.keys()) {
+				for (resource => caps in resources) {
 					final bareJid = JID.parse(chat.chatId);
 					final fullJid = new JID(bareJid.node, bareJid.domain, bareJid.isDomain() && resource == "" ? null : resource);
 					final jigGet = new JabberIqGatewayGet(fullJid.asString(), query);
@@ -111,7 +119,6 @@ class AvailableChatIterator {
 						jigGet.onFinished(() -> {
 							final result = jigGet.getResult();
 							if (result == null) {
-								final caps = chat.getResourceCaps(resource);
 								if (bareJid.isDomain() && caps.features.contains("jid\\20escaping")) {
 									check(new JID(query, bareJid.domain)).then(resolve);
 								} else if (bareJid.isDomain()) {
@@ -119,7 +126,7 @@ class AvailableChatIterator {
 								}
 							} else {
 								switch (result) {
-									case Left(error): resolve(null);
+									case Left(error): resolve([]);
 									case Right(result):
 										check(JID.parse(result)).then(resolve);
 								}
@@ -130,6 +137,11 @@ class AvailableChatIterator {
 				}
 			}
 		}
+
+		// Sort some results to the end
+		for (later in laterResults) {
+			results.push(later);
+		}
 	}
 
 	private function check(jid: JID) {
@@ -140,16 +152,16 @@ class AvailableChatIterator {
 				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") {
-						resolve(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], [], [])));
+						resolve([new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), CapsRepo.empty)]);
 					} else {
-						resolve(null);
+						resolve([]);
 					}
 				} else {
-					client.capsRepo.add(resultCaps);
+					final caps = client.capsRepo.add(resultCaps);
 					final identity = resultCaps.identities[0];
 					final displayName = identity?.name ?? query;
 					final note = jid.asString() + (identity == null ? "" : " (" + identity.type + ")");
-					resolve(new AvailableChat(jid.asString(), displayName, note, resultCaps));
+					resolve([new AvailableChat(jid.asString(), displayName, note, caps)]);
 				}
 			});
 			client.sendQuery(discoGet);
@@ -187,15 +199,23 @@ class AvailableChatIterator {
 	#end
 
 	private function internalNext(): Promise<Null<AvailableChat>> {
-		if (results.length < 1) return Promise.resolve(null);
+		if (head.length > 0) {
+			final available = head.shift();
+
+			if (available != null) {
+				if (dedup.exists(available.chatId)) return internalNext();
 
-		return results.shift().then(available -> {
-			if (available == null || dedup[available.chatId]) {
-				return this.internalNext();
-			} else {
 				dedup[available.chatId] = true;
 				return Promise.resolve(available);
 			}
+		}
+
+		final promise = results.shift();
+		if (promise == null) return Promise.resolve(cast null);
+
+		return promise.then(availables -> {
+			head = availables;
+			return internalNext();
 		});
 	}
 }
diff --git a/borogove/Caps.hx b/borogove/Caps.hx
index 8da2acd..81e8d88 100644
--- a/borogove/Caps.hx
+++ b/borogove/Caps.hx
@@ -13,6 +13,7 @@ import borogove.Util;
 
 // exposed for IDB.js, not really part of public API
 @:expose
+@:nullSafety(StrictThreaded)
 class Caps {
 	public final node: String;
 	public final identities: ReadOnlyArray<Identity>;
@@ -20,34 +21,42 @@ class Caps {
 	public final data: ReadOnlyArray<DataForm>;
 	private var _ver : Null<Hash> = null;
 
-	@:allow(borogove)
-	private static function withIdentity(caps:KeyValueIterator<String, Null<Caps>>, category:Null<String>, type:Null<String>):Array<String> {
-		final result = [];
-		for (cap in caps) {
-			if (cap.value != null) {
-				for (identity in cap.value.identities) {
-					if ((category == null || category == identity.category) && (type == null || type == identity.type)) {
-						result.push(cap.key);
-					}
+	private static function filter(caps:KeyValueIterator<Null<String>, Caps>, predicate:Caps->Bool):KeyValueIterator<Null<String>, Caps> {
+		var nextMatch:Null<{key:Null<String>, value:Caps}> = null;
+
+		final findNext = () -> {
+			while (caps.hasNext()) {
+				var n = caps.next();
+				if (predicate(n.value)) {
+					nextMatch = n;
+					return;
 				}
 			}
-		}
-		return result;
+			nextMatch = null;
+		};
+
+		findNext();
+
+		return {
+			hasNext: () -> nextMatch != null,
+			next: () -> {
+				final r = nextMatch;
+				if (r == null) throw "No more elements";
+
+				findNext();
+				return r;
+			}
+		};
 	}
 
 	@:allow(borogove)
-	private static function withFeature(caps:KeyValueIterator<String, Null<Caps>>, feature:String):Array<String> {
-		final result = [];
-		for (cap in caps) {
-			if (cap.value != null) {
-				for (feat in cap.value.features) {
-					if (feature == feat) {
-						result.push(cap.key);
-					}
-				}
-			}
-		}
-		return result;
+	private static function withIdentity(caps:KeyValueIterator<Null<String>, Caps>, category:Null<String>, type:Null<String>):KeyValueIterator<Null<String>, Caps> {
+		return filter(caps, (c) -> c.identities.exists((identity) -> (category == null || category == identity.category) && (type == null || type == identity.type)));
+	}
+
+	@:allow(borogove)
+	private static function withFeature(caps:KeyValueIterator<Null<String>, Caps>, feature:String):KeyValueIterator<Null<String>, Caps> {
+		return filter(caps, (c) -> c.features.contains(feature));
 	}
 
 	/**
@@ -167,7 +176,9 @@ class Caps {
 			s += feature + "<";
 		}
 		for (form in data) {
-			s += form.field("FORM_TYPE").value[0] + "<";
+			final formType = form.field("FORM_TYPE");
+			s += formType == null ? "" : formType.value[0];
+			s += "<";
 			final fields = form.fields;
 			fields.sort((x, y) -> Reflect.compare(x.name, y.name));
 			for (field in fields) {
@@ -188,8 +199,12 @@ class Caps {
 		Get the raw XEP-0115 capability hash object for this capability set.
 	**/
 	public function verRaw(): Hash {
-		if (_ver == null) _ver = computeVer();
-		return _ver;
+		final ver = _ver;
+		if (ver != null) return ver;
+
+		final newVer = computeVer();
+		_ver = newVer;
+		return newVer;
 	}
 
 	/**
diff --git a/borogove/CapsRepo.hx b/borogove/CapsRepo.hx
index 09b1493..cf44532 100644
--- a/borogove/CapsRepo.hx
+++ b/borogove/CapsRepo.hx
@@ -4,6 +4,7 @@ import thenshim.Promise;
 
 @:nullSafety(StrictThreaded)
 class CapsRepo {
+	public static final empty = new Caps("", [], [], []);
 	private final persistence: Persistence;
 	private final cache: Map<String, Caps> = [];
 
@@ -11,9 +12,16 @@ class CapsRepo {
 		this.persistence = persistence;
 	}
 
-	public function add(caps: Caps) {
-		persistence.storeCaps(caps);
-		cache[caps.ver()] = caps;
+	public function add(caps: Caps, storeOnMiss = true) {
+		final ver = caps.ver();
+		final r = cache[ver];
+		if (r == null) {
+			if (storeOnMiss) persistence.storeCaps(caps);
+			cache[ver] = caps;
+			return caps;
+		}
+
+		return r;
 	}
 
 	public function getAsync(presence: Presence): Promise<Null<Caps>> {
@@ -39,6 +47,6 @@ class CapsRepo {
 			getAsync(presence); // Fetch and put in cache for later
 		}
 
-		return new Caps("", [], [], []);
+		return empty;
 	}
 }
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index fc71309..522ecdc 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -30,26 +30,30 @@ import HaxeCBridge;
 #end
 
 #if js
-typedef StringMapNullableKey = Map<Null<String>, String>;
+typedef StringMapNullableKey<T> = Map<Null<String>, T>;
 #else
 final nullSentinel = "65e2ca3a-a13e-490c-bfe6-9c6b4c8651d0";
 
 @:forward
-abstract StringMapNullableKey(haxe.ds.StringMap<String>) {
+abstract StringMapNullableKey<T>(haxe.ds.StringMap<T>) {
 	public inline function new() {
 		this = new haxe.ds.StringMap();
 	}
 
-	public inline function set(k: Null<String>, v: String) {
+	@:arrayAccess public inline function set(k: Null<String>, v: T) {
 		this.set(k == null ? nullSentinel : k, v);
 	}
 
-	public inline function get(k: Null<String>) {
-		return this.get(k == null ? nullSentinel : k,);
+	@:arrayAccess public inline function get(k: Null<String>) {
+		return this.get(k == null ? nullSentinel : k);
 	}
 
 	public inline function remove(k: Null<String>) {
-		return this.remove(k == null ? nullSentinel : k,);
+		return this.remove(k == null ? nullSentinel : k);
+	}
+
+	public inline function exists(k: Null<String>) {
+		return this.exists(k == null ? nullSentinel : k);
 	}
 
 	public inline function keyValueIterator() {
@@ -104,14 +108,13 @@ enum abstract EncryptionMode(Int) {
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
 #end
-abstract class Chat {
+abstract class Chat extends EventEmitter {
 	private var client:Client;
 	private var stream:GenericStream;
 	private var persistence:Persistence;
 	@:allow(borogove)
 	private var avatarSha1:Null<BytesData> = null;
-	@:allow(borogove)
-	private var presence:Map<String, Presence> = [];
+	@:allow(borogove.SerializedChat)
 	private var trusted:Bool = false;
 	@:allow(borogove)
 	public var status(default, null):Status = new Status("", "");
@@ -150,7 +153,7 @@ abstract class Chat {
 	@:allow(borogove)
 	private var readUpToBy: Null<String>;
 	@:allow(borogove.persistence)
-	private final threads: StringMapNullableKey = new StringMapNullableKey();
+	private final threads: StringMapNullableKey<String> = new StringMapNullableKey();
 	private var isTyping = false;
 	private var typingThread: Null<String> = null;
 	private var typingTimer: haxe.Timer = null;
@@ -165,6 +168,7 @@ abstract class Chat {
 
 	@:allow(borogove)
 	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) {
+		super();
 		if (chatId == null || chatId == "") {
 			throw "chatId may not be empty";
 		}
@@ -268,42 +272,63 @@ abstract class Chat {
 	abstract public function bookmark():Void;
 
 	/**
-		Get the list of IDs of participants in this Chat
+		Get a list of members in this Chat
 
-		@returns array of IDs
+		This list will often be a a complete list of everyone who has access to
+		the chat, but for larger chats may be incomplete.
 	**/
-	abstract public function getParticipants():Array<String>;
+	abstract public function members():Promise<Array<Member>>;
+
+	/**
+		Get the details for some members in this Chat
+
+		@param memberIds the IDs of the member to look up
+		**/
+	abstract public function getMemberDetails(memberIds: Array<String>):Promise<Array<Null<Member>>>;
 
 	/**
-		Roles the current user can assign to the target participant
+		Event fired when a member is updated, or when a new member is added
+
+		@param handler takes one argument, an array of Member that were updated
+		@returns token for use with removeEventListener
 	**/
-	public function availableRoles(participantId: String):Array<Role> {
-		return [];
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
+	public function addMembersUpdatedListener(handler: (Array<Member>)->Void) {
+		return this.on("members/update", (data) -> {
+			handler(data);
+			return EventHandled;
+		});
 	}
 
 	/**
-		Can the current user remove this role from the participant?
+		Roles the current user can assign to the target member
 	**/
-	public function canRemoveRole(participantId: String, role: Role):Bool {
-		return false;
+	public function availableRoles(member: Member):Array<Role> {
+		return [];
 	}
 
 	/**
-		Add a role to a participant
+		Can the current user remove this role from the member?
 	**/
-	public function addRole(participantId: String, role: Role) { }
+	public function canRemoveRole(member: Member, role: Role):Bool {
+		return false;
+	}
 
 	/**
-		Remove a role from a participant
+		Add a role to a member
+
+		@param member the member to update
+		@param role the role to add
 	**/
-	public function removeRole(participantId: String, role: Role) { }
+	public function addRole(member: Member, role: Role) { }
 
 	/**
-		Get the details for one participant in this Chat
+		Remove a role from a member
 
-		@param participantId the ID of the participant to look up
+		@param member the member to update
+		@param role the role to remove
 	**/
-	abstract public function getParticipantDetails(participantId: String):Participant;
+	public function removeRole(member: Member, role: Role) { }
 
 	/**
 		Correct an already-send message by replacing it with a new one
@@ -553,11 +578,6 @@ abstract class Chat {
 		return notificationSettings == null || notificationSettings.reply;
 	}
 
-	/**
-		An ID of the most recent message in this chat
-	**/
-	abstract public function lastMessageId():Null<String>;
-
 	@:allow(borogove)
 	private function updateFromBookmark(item: Stanza) {
 		isBookmarked = true;
@@ -638,8 +658,9 @@ abstract class Chat {
 	}
 
 	@:allow(borogove)
-	private function setLastMessage(message:Null<ChatMessage>) {
+	private function setLastMessage(message:Null<ChatMessage>): Promise<Any> {
 		lastMessage = message;
+		return Promise.resolve(null);
 	}
 
 	/**
@@ -658,14 +679,6 @@ abstract class Chat {
 	public function getDisplayName() {
 		if (this.displayName == chatId) {
 			if (chatId == client.accountId()) return client.displayName();
-
-			final participants = getParticipants();
-			if (participants.length > 2 && participants.length < 20) {
-				return participants.map(id -> {
-					final p = id == chatId ? null : getParticipantDetails(id);
-					p == null || p.isSelf ? null : p.displayName;
-				}).filter(fn -> fn != null).join(", ");
-			}
 		} else if (uiState == Invited) {
 			return '${displayName} (${chatId})';
 		}
@@ -686,32 +699,10 @@ abstract class Chat {
 	}
 
 	@:allow(borogove)
-	private function setPresence(resource:String, presence:Presence) {
-		this.presence.set(resource, presence);
-	}
-
-	@:allow(borogove)
-	private function removePresence(resource:String) {
-		presence.remove(resource);
-	}
-
-	@:allow(borogove)
-	private function getCaps():KeyValueIterator<String, Caps> {
-		final iter = presence.keyValueIterator();
-		return {
-			hasNext: iter.hasNext,
-			next: () -> {
-				final n = iter.next();
-				return { key: n.key, value: client.capsRepo.get(n.value) };
-			}
-		};
-	}
+	abstract private function setPresence(resource:String, presence:Presence, noStore:Bool = false):Void;
 
 	@:allow(borogove)
-	private function getResourceCaps(resource:String):Caps {
-		final p = presence[resource];
-		return p == null ? new Caps("", [], [], []) : client.capsRepo.get(p);
-	}
+	abstract private function getCaps():KeyValueIterator<Null<String>, Caps>;
 
 	@:allow(borogove)
 	private function setAvatarSha1(sha1: BytesData) {
@@ -866,7 +857,7 @@ abstract class Chat {
 		Can the user send messages to this chat?
 	**/
 	public function canSend() {
-		return Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").length < 1;
+		return !Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").hasNext();
 	}
 
 	/**
@@ -909,17 +900,17 @@ abstract class Chat {
 			return chatId.indexOf("@") < 0 && hasCommands();
 		}
 
-		final bot = Caps.withIdentity(getCaps(), "client", "bot").length > 0;
-		final client = Caps.withIdentity(getCaps(), "client", null).length > 0;
-		final account = Caps.withIdentity(getCaps(), "account", null).length > 0;
+		final bot = Caps.withIdentity(getCaps(), "client", "bot").hasNext();
+		final client = Caps.withIdentity(getCaps(), "client", null).hasNext();
+		final account = Caps.withIdentity(getCaps(), "account", null).hasNext();
 		// Clients are not apps, we chat with them
 		if ((client || account) && !bot) return false;
 
-		final noReply = Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").length > 0;
+		final noReply = Caps.withFeature(getCaps(), "urn:xmpp:noreply:0").hasNext();
 		// A bot that doesn't want messages is an app
 		if (bot && noReply) return hasCommands();
 
-		final conference = Caps.withIdentity(getCaps(), "conference", null).length > 0;
+		final conference = Caps.withIdentity(getCaps(), "conference", null).hasNext();
 		// A MUC component is an app
 		if (conference && chatId.indexOf("@") < 0) return hasCommands();
 
@@ -955,8 +946,8 @@ abstract class Chat {
 	private function commandJids() {
 		final jids = [];
 		final jid = JID.parse(chatId);
-		for (resource in Caps.withFeature(getCaps(), "http://jabber.org/protocol/commands")) {
-			jids.push(resource == "" || resource == null ? jid : jid.withResource(resource));
+		for (resource => caps in Caps.withFeature(getCaps(), "http://jabber.org/protocol/commands")) {
+			jids.push(resource == null ? jid : jid.withResource(resource));
 		}
 		if (jids.length < 1 && jid.isDomain()) {
 			jids.push(jid);
@@ -965,11 +956,11 @@ abstract class Chat {
 	}
 
 	/**
-		The Participant that originally invited us to this Chat, if we were invited
+		The Member that originally invited us to this Chat, if we were invited
 	**/
 	public function invitedBy() {
 		final inviteEls = invites();
-		if (inviteEls.length < 1) return null;
+		if (inviteEls.length < 1) return Promise.resolve(null);
 
 		final inviteFrom = JID.parse(inviteEls[0].attr.get("from"));
 		final bare = inviteFrom.asBare().asString();
@@ -977,11 +968,13 @@ abstract class Chat {
 		if (maybeChannel != null) {
 			final channel = maybeChannel.downcast(Channel);
 			if (channel != null) {
-				return channel.getParticipantDetails(inviteFrom.asString());
+				members()
+					.then(members -> members.filter(member -> member.displayName == inviteFrom.resource))
+					.then(result -> result.length > 0 ? result[0] : null);
 			}
 		}
 
-		return (maybeChannel ?? client.getDirectChat(bare)).getParticipantDetails(bare);
+		return (maybeChannel ?? client.getDirectChat(bare)).getMemberDetails([bare]).then(result -> result[0] != null ? result[0] : null);
 	}
 
 	private function invites() {
@@ -1004,7 +997,7 @@ abstract class Chat {
 	}
 
 	@:allow(borogove)
-	private function markReadUpToId(upTo: String, upToBy: String): Promise<Any> {
+	private function markReadUpToId(upTo: String, upToBy: String, recompute = true): Promise<Any> {
 		if (upTo == null) return Promise.reject(null);
 		if (readUpToId == upTo) {
 			return Promise.reject(null);
@@ -1012,8 +1005,8 @@ abstract class Chat {
 
 		readUpToId = upTo;
 		readUpToBy = upToBy;
-		persistence.storeChats(client.accountId(), [this]);
-		return recomputeUnread();
+		if (recompute) persistence.storeChats(client.accountId(), [this]);
+		return recompute ? recomputeUnread() : Promise.resolve(null);
 	}
 
 	private function markReadUpToMessage(message: ChatMessage): Promise<Any> {
@@ -1061,46 +1054,100 @@ abstract class Chat {
 @:build(HaxeSwiftBridge.expose())
 #end
 class DirectChat extends Chat {
+	@:allow(borogove.Client)
+	private final presence: Map<String, Presence> = new Map();
+	private var _fullCounterparts: Array<String>;
+
 	@:allow(borogove)
 	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) {
 		super(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
+		_fullCounterparts = counterparts();
 		outbox.start();
 	}
 
-	@HaxeCBridge.noemit // on superclass as abstract
-	public function getParticipants(): Array<String> {
-		final counters = counterparts();
-		final ids: Map<String, Bool> = [];
-		if (counters.length < 2 && (lastMessage?.recipients?.length ?? 0) > 1) {
-			ids[lastMessage.senderId] = true;
-			for (id in lastMessage.recipients.map(r -> r.asString())) {
-				ids[id] = true;
-			}
+	@:allow(borogove)
+	private function setPresence(resource:String, presence:Presence, noStore = false) {
+		// We only store presence for 1:1
+		if (_fullCounterparts.length > 1) return;
+
+		if (presence.type == "unavailable") {
+			this.presence.remove(resource);
 		} else {
-			ids[client.accountId()] = true;
-			for (id in counterparts()) {
-				ids[id] = true;
+			this.presence.set(resource, presence);
+		}
+
+		if (noStore) return;
+
+		getMemberDetails(_fullCounterparts)
+			.then(members -> persistence.storeMembers(client.accountId(), chatId, members).then(_ -> members))
+			.then(members -> {
+				trigger("members/update", members);
+				client.trigger("chats/update", [this]);
+				return null;
+			});
+	}
+
+	@:allow(borogove)
+	private function getCaps():KeyValueIterator<Null<String>, Caps> {
+		final iter = presence.keyValueIterator();
+		return {
+			hasNext: iter.hasNext,
+			next: () -> {
+				final n = iter.next();
+				return { key: n.key, value: client.capsRepo.get(n.value) };
 			}
+		};
+	}
+
+	@:allow(borogove)
+	private function getResourceCaps(resource: Null<String>):Caps {
+		final p = presence[resource];
+		return p == null ? CapsRepo.empty : client.capsRepo.get(p);
+	}
+
+	override public function getDisplayName() {
+		final name = super.getDisplayName();
+		if (name == chatId && _fullCounterparts.length > 1) {
+			final names = _fullCounterparts.map(id -> client.getDirectChat(id).getDisplayName());
+			names.sort(Reflect.compare);
+			return names.join(", ");
 		}
-		return { iterator: () -> ids.keys() }.array();
+
+		return name;
 	}
 
-	private function counterparts() {
-		return chatId.split("\n");
+	@HaxeCBridge.noemit // on superclass as abstract
+	public function members() {
+		return getMemberDetails(_fullCounterparts.concat([client.accountId()]));
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function getParticipantDetails(participantId:String): Participant {
-		final chat = client.getDirectChat(participantId);
-		return new Participant(
-			chat.getDisplayName(),
-			chat.getPhoto(),
-			chat.getPlaceholder(),
-			chat.chatId == client.accountId(),
-			[], // No roles in direct chat
-			JID.parse(participantId),
-			new AvailableChat(participantId, chat.getDisplayName(), '${participantId} (via ${displayName})', new Caps("", [], [], []))
-		);
+	public function getMemberDetails(memberIds: Array<String>) {
+		return Promise.resolve(memberIds.map(id -> {
+			final chat = client.getDirectChat(id);
+			return new Member(
+				id,
+				chat.getDisplayName(),
+				chat.getPhoto(),
+				chat.chatId == client.accountId(),
+				[], // No roles in direct chat
+				JID.parse(id),
+				chat.presence,
+				new AvailableChat(id, chat.getDisplayName(), '${id} (via ${displayName})', CapsRepo.empty)
+			);
+		}));
+	}
+
+	override private function setLastMessage(message:Null<ChatMessage>): Promise<Any> {
+		// If last message at load time is a sent message, this may not include everyone
+		if (_fullCounterparts.length < 2 && (message?.recipients?.length ?? 0) > 1) {
+			_fullCounterparts = message.recipients.map(r -> r.asBare().asString()).filter(id -> id != client.accountId());
+		}
+		return super.setLastMessage(message);
+	}
+
+	private function counterparts() {
+		return chatId.split("\n");
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -1177,8 +1224,9 @@ class DirectChat extends Chat {
 			message.localId = toSendId;
 			sendMessageStanza(message.build().asStanza(), outboxItem);
 			if (lastMessage == null || corrected.canReplace(lastMessage)) {
-				setLastMessage(corrected);
-				client.trigger("chats/update", [this]);
+				setLastMessage(corrected).then(_ ->
+					client.trigger("chats/update", [this])
+				);
 			}
 			client.notifyMessageHandlers(corrected, CorrectionEvent);
 		});
@@ -1203,9 +1251,10 @@ class DirectChat extends Chat {
 						stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up();
 					}
 					sendMessageStanza(stanza, outboxItem);
-					setLastMessage(stored);
-					client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
-					client.trigger("chats/update", [this]);
+					setLastMessage(stored).then(_ -> {
+						client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
+						client.trigger("chats/update", [this]);
+					});
 				});
 			case ReactionUpdateStanza(update):
 				persistence.storeReaction(client.accountId(), update).then((stored) -> {
@@ -1287,11 +1336,6 @@ class DirectChat extends Chat {
 		});
 	}
 
-	@HaxeCBridge.noemit // on superclass as abstract
-	public function lastMessageId() {
-		return lastMessage?.serverId ?? lastMessage?.localId;
-	}
-
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function markReadUpTo(message: ChatMessage) {
 		// A PM is not actually part of the chat
@@ -1391,8 +1435,14 @@ class Channel extends Chat {
 	private var joinFailed = null;
 	private var sync = null;
 	private var forceLive = false;
-	private var _nickInUse = null;
+	@:allow(borogove)
+	private var self: Null<Member> = null;
+	@:allow(borogove)
+	private var mavUntil = null;
+	@:allow(borogove.SerializedChat)
+	private var membersForName: Null<Array<{id: String, displayName: String}>> = [];
 	private var sortId = null;
+	private var lastMessageSenderName = null;
 
 	@:allow(borogove)
 	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBookmarked = false, isBlocked = false, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) {
@@ -1449,29 +1499,35 @@ class Channel extends Chat {
 	}
 
 	@:allow(borogove)
-	private function join() {
+	private function join(): Promise<Any> {
 		if (uiState == Invited || uiState == Closed) {
 			// Do not join
-			return;
+			return Promise.resolve(null);
 		}
 
-		presence = []; // About to ask for a fresh set
-		_nickInUse = null;
-		outbox.pause();
-		inSync = false;
-		client.trigger("chats/update", [this]);
-		final desiredFullJid = JID.parse(chatId).withResource(client.displayName());
-		client.sendPresence(
-			desiredFullJid.asString(),
-			(stanza) -> {
-				stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" });
-				if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up();
-				// TODO: else since (last message we know about)
-				stanza.up();
-				return stanza;
-			}
-		);
-		persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point));
+		return persistence.clearMemberPresence(client.accountId(), chatId).then(_ -> {
+			self = null;
+			outbox.pause();
+			inSync = false;
+			client.trigger("chats/update", [this]);
+			final desiredFullJid = JID.parse(chatId).withResource(client.displayName());
+			client.sendPresence(
+				desiredFullJid.asString(),
+				(stanza) -> {
+					final mavAttr: haxe.DynamicAccess<String> = { xmlns: "urn:xmpp:muc:affiliations:1" };
+					if (mavUntil != null) mavAttr.set("since", mavUntil);
+					stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" })
+						.tag("mav", mavAttr).up();
+					if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up();
+					// TODO: else since (last message we know about)
+					stanza.up();
+					return stanza;
+				}
+			);
+			persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point));
+
+			return null;
+		});
 	}
 
 	private function selfPingSuccess() {
@@ -1486,12 +1542,15 @@ class Channel extends Chat {
 	}
 
 	override public function getDisplayName() {
-		if (this.displayName == chatId) {
+		final name = super.getDisplayName();
+		if (name == chatId) {
 			final title = (info()?.field("muc#roomconfig_roomname")?.value ?? []).join("\n");
 			if (title != null && title != "") return title;
+
+			if (membersForName != null && membersForName.length > 0 && membersForName.length < 20) return membersForName.map(m -> m.displayName).join(", ");
 		}
 
-		return super.getDisplayName();
+		return name;
 	}
 
 	/**
@@ -1527,63 +1586,104 @@ class Channel extends Chat {
 
 	override public function canInvite() {
 		if (!isPrivate()) return true;
-		if (_nickInUse == null) return false;
+		if (self == null) return false;
 
-		final p = presence[_nickInUse];
-		if (p == null) return false;
+		final it = self.presence.iterator();
+		if (!it.hasNext()) return false;
 
-		if (p.mucUser.role == "moderator") return true;
+		if (it.next().mucUser.role == "moderator") return true;
 
 		return false;
 	}
 
 	override public function canSend() {
 		if (!super.canSend()) return false;
-		if (_nickInUse == null) return true;
+		if (self == null) return true;
 
-		final p = presence[_nickInUse];
-		if (p == null) return true;
+		final it = self.presence.iterator();
+		if (!it.hasNext()) return true;
 
-		return p.mucUser.role != "visitor";
+		return it.next().mucUser.role != "visitor";
 	}
 
 	override public function canModerate() {
-		if (_nickInUse == null) return false;
+		if (self == null || disco == null) return false;
 
-		final p = presence[_nickInUse];
-		if (p == null) return false;
+		final it = self.presence.iterator();
+		if (!it.hasNext()) return false;
 
-		return disco.features.contains("urn:xmpp:message-moderate:1") && p.mucUser.role == "moderator";
+		return disco.features.contains("urn:xmpp:message-moderate:1") && it.next().mucUser.role == "moderator";
 	}
 
 	@:allow(borogove)
-	override private function getCaps():KeyValueIterator<String, Caps> {
-		return ["" => disco].keyValueIterator();
+	private function getCaps():KeyValueIterator<Null<String>, Caps> {
+		var hasNext = true;
+		return {
+			hasNext: () -> hasNext,
+			next: () -> {
+				hasNext = false;
+				return { key: null, value: disco };
+			}
+		};
 	}
 
 	@:allow(borogove)
-	override private function setPresence(resource:String, presence:Presence) {
+	private function setPresence(resource:String, presence:Presence, noStore = false) {
 		final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110");
-		if (oneTen != null) {
-			_nickInUse = resource;
-			outbox.start();
-		} else if (resource == _nickInUse) {
-			_nickInUse = null;
-			outbox.pause();
+		final member = buildMember(resource, presence);
+		if (presence.mucUser != null && oneTen == null && member.isSelf) {
+			// ejabberd sends presence updates for self without 110
+			final mucUser: Stanza = presence.mucUser;
+			mucUser.tag("status", { code: "110" }).up();
+			setPresence(resource, presence);
+			return;
 		}
-		if (presence != null && presence.mucUser != null && oneTen == null) {
-			final existing = this.presence.get(resource);
-			if (existing != null && existing?.mucUser?.statusCodes?.find((status) -> status == "110") != null) {
-				final mucUser: Stanza = presence.mucUser;
-				mucUser.tag("status", { code: "110" }).up();
-				setPresence(resource, presence);
-				return;
+		final occupantId = (presence : Stanza).getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
+		if (!noStore && !(disco.features.contains("urn:xmpp:occupant-id:0") && member.id != chatId + "/" + occupantId)) {
+			(if (presence.type == "unavailable" && member.photoUri == null) {
+				final memberUpdates = MemberUpdate.extractUpdates(client.accountId(), this, presence);
+				getMemberDetails([member.id]).then(fetched ->
+					cast memberUpdates[0].applyTo(fetched[0])
+				);
+			} else {
+				Promise.resolve(member);
+			}).then(member -> {
+				persistence.storeMembers(client.accountId(), chatId, [member]).then(_ -> {
+					trigger("members/update", [member]);
+				});
+			});
+		}
+		final mucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user");
+		if (mucUser != null) {
+			final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1");
+			if (mav?.attr?.get("since") != null && mav?.attr?.get("since") != mavUntil) {
+				trace("MAV update with unknown previous version", mavUntil, presence);
+			}
+			if (mav?.attr?.get("until") != null && mavUntil != mav?.attr?.get("until")) {
+				mavUntil = mav?.attr?.get("until");
+				persistence.storeChats(client.accountId(), [this]);
+			}
+		}
+		if (member.isSelf) {
+			if (presence.type == "unavailable") {
+				self = null;
+				outbox.pause();
+			} else {
+				self = member;
+				outbox.start();
 			}
+			client.trigger("chats/update", [this]);
+		}
+		if (!member.isSelf && member.id != chatId && membersForName != null) {
+			membersForName = membersForName.filter(m -> m.id != member.id);
+			membersForName.push({ id: member.id, displayName: member.displayName });
+			membersForName.sort((a, b) -> Reflect.compare(a.displayName, b.displayName));
+			if (membersForName.length > 20) membersForName = null;
+			if (displayName == chatId) client.trigger("chats/update", [this]);
 		}
-		super.setPresence(resource, presence);
 		final tripleThree = presence?.mucUser?.statusCodes?.find((status) -> status == "333");
-		if (oneTen != null && tripleThree != null) {
-			selfPing(true);
+		if (member.isSelf && tripleThree != null) {
+			haxe.Timer.delay(() -> selfPing(true), 5000);
 		}
 	}
 
@@ -1677,19 +1777,22 @@ class Channel extends Chat {
 					// Sort by time so that eg edits go into the past
 					dedupedMessages.sort((x, y) -> Reflect.compare(x.timestamp, y.timestamp));
 
-					final lastFromSync = dedupedMessages[dedupedMessages.length - 1];
-					if (lastFromSync != null && (lastMessage == null || lastFromSync.sortId > lastMessage.sortId)) {
-						setLastMessage(lastFromSync);
-						client.sortChats();
-					}
-
 					final readIndex = dedupedMessages.findLastIndex((m) -> m.serverId == readUpToId || !m.isIncoming());
 					if (readIndex < 0) {
 						setUnreadCount(unreadCount() + dedupedMessages.length);
 					} else {
 						setUnreadCount(dedupedMessages.length - readIndex - 1);
 					}
-					client.trigger("chats/update", [this]);
+
+					final lastFromSync = dedupedMessages[dedupedMessages.length - 1];
+					if (lastFromSync != null && (lastMessage == null || lastFromSync.sortId > lastMessage.sortId)) {
+						setLastMessage(lastFromSync).then(_ -> {
+							client.sortChats();
+							client.trigger("chats/update", [this]);
+						});
+					} else {
+						Promise.resolve(client.trigger("chats/update", [this]));
+					}
 				}
 			});
 		});
@@ -1734,8 +1837,7 @@ class Channel extends Chat {
 		final discoGet = new DiscoInfoGet(chatId);
 		discoGet.onFinished(() -> {
 			if (discoGet.getResult() != null) {
-				disco = discoGet.getResult();
-				client.capsRepo.add(discoGet.getResult());
+				disco = client.capsRepo.add(discoGet.getResult());
 				persistence.storeChats(client.accountId(), [this]);
 			}
 			if (callback != null) callback();
@@ -1745,7 +1847,6 @@ class Channel extends Chat {
 				avatarSha1 = hash.hash;
 				persistence.hasMedia("sha-1", avatarSha1).then((has) -> {
 					if (!has) {
-trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1Hex);
 						final vcardGet = new VcardTempGet(JID.parse(chatId));
 						vcardGet.onFinished(() -> {
 							final vcard = vcardGet.getResult();
@@ -1765,14 +1866,14 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	override public function preview() {
 		if (lastMessage == null) return super.preview();
 
-		return getParticipantDetails(lastMessage.senderId).displayName + ": " + super.preview();
+		return lastMessageSenderName + ": " + super.preview();
 	}
 
 	@:allow(borogove)
 	override private function livePresence() {
 		if (forceLive) return true;
 
-		return _nickInUse != null;
+		return self != null;
 	}
 
 	override public function syncing() {
@@ -1780,8 +1881,15 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	}
 
 	override private function setLastMessage(message:Null<ChatMessage>) {
-		super.setLastMessage(message);
-		if (message != null && message.type == MessageChannel && (sortId == null || sortId < message.sortId)) sortId = message.sortId;
+		return super.setLastMessage(message).then(_ -> {
+			if (message != null && message.type == MessageChannel && (sortId == null || sortId < message.sortId)) sortId = message.sortId;
+			if (message == null) return Promise.resolve(null);
+
+			return getMemberDetails([message.senderId]).then(sender -> {
+				lastMessageSenderName = sender[0] != null ? sender[0].displayName : message.senderMemberStub().displayName;
+				return null;
+			});
+		});
 	}
 
 	override public function canAudioCall():Bool {
@@ -1793,7 +1901,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	}
 
 	private function nickInUse() {
-		return _nickInUse ?? client.displayName();
+		return self?.displayName ?? client.displayName();
 	}
 
 	@:allow(borogove)
@@ -1802,115 +1910,101 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function getParticipants() {
-		final jid = JID.parse(chatId);
-		return { iterator: () -> presence.keys() }.filter(resource -> resource != null).map((resource) -> new JID(jid.node, jid.domain, resource).asString());
+	public function members() {
+		return persistence
+			.getMembers(client.accountId(), this, self == null ? false : 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 getParticipantDetails(participantId:String): Participant {
-		final jid = JID.parse(participantId);
-		final nick = jid.resource;
-		final ppresence = presence[nick];
-		final roles = ppresence?.hats ?? [];
-		if (ppresence?.mucUser != null) {
-			final affRole = Role.forAffiliation(ppresence.mucUser.affiliation);
+	public function getMemberDetails(memberIds: Array<String>) {
+		if (memberIds.length == 1 && self != null && memberIds[0] == self.id) return Promise.resolve([cast self]);
+
+		return persistence.getMemberDetails(client.accountId(), this, memberIds);
+	}
+
+	private function buildMember(resource: String, presence: Presence): Member {
+		final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110");
+		final jid = JID.parse(chatId).withResource(resource);
+		final nick = resource ?? getDisplayName();
+		final roles = presence?.hats ?? [];
+		final occupantId = (presence : Stanza).getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
+		final id = occupantId == null ? jid.asString() : chatId + "/" + occupantId;
+		if (presence?.mucUser != null) {
+			final affRole = Role.forAffiliation(presence.mucUser.affiliation);
 			if (affRole != null) roles.unshift(affRole);
 		}
-		if (participantId == getFullJid().asString()) {
+		if (oneTen != null || id == self?.id) {
 			final chat = client.getDirectChat(client.accountId(), false);
-			return new Participant(
-				client.displayName(),
+			return new Member(
+				id,
+				nick,
 				chat.getPhoto(),
-				chat.getPlaceholder(),
 				true,
 				roles,
 				JID.parse(chat.chatId),
-				new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, new Caps("", [], [], []))
+				[ nick => presence ],
+				new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, CapsRepo.empty)
 			);
 		} else {
-			final placeholderUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0));
-			final trueJid = ppresence?.mucUser?.jid?.asBare()?.asString();
-			return new Participant(
-				nick ?? "",
-				ppresence?.avatarHash?.toUri(),
-				placeholderUri,
+			final trueJid = presence?.mucUser?.jid?.asBare()?.asString();
+			return new Member(
+				id,
+				nick,
+				presence?.avatarHash?.toUri(),
 				false,
 				roles,
 				trueJid == null ? jid : JID.parse(trueJid),
-				trueJid == null ? null : new AvailableChat(trueJid, nick ?? "", '$trueJid (via ${displayName})', new Caps("", [], [], []))
+				[ nick => presence ],
+				trueJid == null ? null : new AvailableChat(trueJid, nick, '$trueJid (via ${displayName})', CapsRepo.empty)
 			);
 		}
 	}
 
-	override public function availableRoles(participantId: String):Array<Role> {
-		if (_nickInUse == null) return [];
+	override public function availableRoles(member: Member):Array<Role> {
+		if (self == null) return [];
 
-		final p = presence[_nickInUse];
-		if (p?.mucUser == null) return [];
-
-		// TODO: this should get their affiliation from the list not using presence
-		// once we are fetching the affiliation list
-		// That's probably true everywhere we use affiliation
-		final pjid = JID.parse(participantId);
-		final pnick = pjid.resource;
-		final ppresence = presence[pnick];
-		if (ppresence?.mucUser == null) return [];
-		if (ppresence?.mucUser?.jid == null) return [];
-
-		if (p.mucUser.affiliation == "owner") {
-			return ["owner", "admin", "member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff));
+		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));
 		}
 
-		if (p.mucUser.affiliation == "admin") {
-			if (ppresence.mucUser.affiliation == "owner") return [];
+		if (self.roles.exists(r -> r.id == "admin")) {
+			if (member.roles.exists(r -> r.id == "owner")) return [];
 
-			return ["member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff));
+			return ["none", "outcast"].filter(aff -> !member.roles.exists(r -> r.id == aff)).map(aff -> Role.forAffiliation(aff));
 		}
 
 		return [];
 	}
 
-	override public function canRemoveRole(participantId: String, role: Role):Bool {
-		if (_nickInUse == null) return false;
-
-		final p = presence[_nickInUse];
-		if (p?.mucUser == null) return false;
+	override public function canRemoveRole(member: Member, role: Role):Bool {
+		if (self == null) return false;
 
-		final pjid = JID.parse(participantId);
-		final pnick = pjid.resource;
-		final ppresence = presence[pnick];
-		if (ppresence?.mucUser == null) return false;
-		if (ppresence?.mucUser?.jid == null) return false;
-
-		if (p.mucUser.affiliation == "owner") {
-			return ["owner", "admin", "member", "outcast"].contains(role.id);
+		if (self.roles.exists(r -> r.id == "owner")) {
+			return ["owner", "admin", "none", "outcast"].contains(role.id);
 		}
 
-		if (p.mucUser.affiliation == "admin") {
-			if (ppresence.mucUser.affiliation == "owner") return false;
+		if (self.roles.exists(r -> r.id == "admin")) {
+			if (member.roles.exists(r -> r.id == "owner")) return false;
 
-			return ["admin", "member", "outcast"].contains(role.id);
+			return ["admin", "none", "outcast"].contains(role.id);
 		}
 
 		return false;
 	}
 
-	override public function addRole(participantId: String, role: Role) {
-		final pjid = JID.parse(participantId);
-		final pnick = pjid.resource;
-		final ppresence = presence[pnick];
-		if (ppresence?.mucUser?.jid == null) return;
+	override public function addRole(member: Member, role: Role) {
+		if (member.chat == null) return; // We don't know their jid
 
 		final iq = new Stanza("iq", { type: "set", to: chatId })
 			.tag("query", { xmlns: "http://jabber.org/protocol/muc#admin" })
-			.textTag("item", "", { affiliation: role.id, jid: ppresence.mucUser.jid.asBare().asString() });
+			.textTag("item", "", { affiliation: role.id, jid: member.chat.chatId });
 		stream.sendIq(iq, (response) -> {});
 	}
 
-	override public function removeRole(participantId: String, role: Role) {
-		// For affiliation-backed roles we remove them by adding affiliation of none
-		addRole(participantId, new Role("none", ""));
+	override public function removeRole(member: Member, role: Role) {
+		// For affiliation-backed roles we remove them by adding affiliation of member, which we treat as "no role"
+		addRole(member, new Role("member", ""));
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -1977,8 +2071,9 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 				message.sortId = client.nextSortId();
 			}
 		}
-		message.senderId = stanza.attr.get("from"); // MUC always needs full JIDs
-		if (message.senderId == getFullJid().asString()) {
+		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) {
 			message.recipients = message.replyTo;
 			message.direction = MessageSent;
 		}
@@ -1990,8 +2085,8 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 		message.timestamp = message.timestamp ?? Date.format(std.Date.now());
 		message.direction = MessageSent;
 		message.from = client.jid;
-		message.sender = getFullJid();
-		message.replyTo = [message.sender];
+		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.replyTo = [getFullJid()];
 		message.to = JID.parse(chatId);
 		message.recipients = [message.to];
 		if (message.localId == null) message.localId = ID.unique();
@@ -2012,8 +2107,9 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 			sendMessageStanza(message.build().asStanza(), outboxItem);
 			client.notifyMessageHandlers(corrected, CorrectionEvent);
 			if (lastMessage == null || corrected.canReplace(lastMessage)) {
-				setLastMessage(corrected);
-				client.trigger("chats/update", [this]);
+				setLastMessage(corrected).then(_ ->
+					client.trigger("chats/update", [this])
+				);
 			}
 		});
 	}
@@ -2027,7 +2123,10 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 		final stanza = message.build().asStanza();
 		// Fake from as it will look on reflection for storage purposes
 		stanza.attr.set("from", getFullJid().asString());
-		final fromStanza = Message.fromStanza(stanza, client.jid).parsed;
+		final fromStanza = Message.fromStanza(stanza, client.jid, (builder, _) -> {
+			if (message.senderId != null) builder.senderId = message.senderId;
+			return builder;
+		}).parsed;
 		stanza.attr.set("from", client.jid.asString());
 		switch (fromStanza) {
 			case ChatMessageStanza(_):
@@ -2039,9 +2138,10 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 				final outboxItem = outbox.newItem();
 				client.storeMessageBuilder(message).then((stored) -> {
 					sendMessageStanza(stanza, outboxItem);
-					setLastMessage(stored);
-					client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
-					client.trigger("chats/update", [this]);
+					setLastMessage(stored).then(_ -> {
+						client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
+						client.trigger("chats/update", [this]);
+					});
 				});
 			case ReactionUpdateStanza(update):
 				persistence.storeReaction(client.accountId(), update).then((stored) -> {
@@ -2073,11 +2173,11 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 		final reactions = [];
 		for (areaction => reacts in m.reactions) {
 			if (areaction != reaction.key) {
-				final react = reacts.find(r -> r.senderId == getFullJid().asString());
+				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(), getFullJid().asString(), 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);
@@ -2094,11 +2194,6 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 		outboxItem.handle(() -> client.sendStanza(stanza));
 	}
 
-	@HaxeCBridge.noemit // on superclass as abstract
-	public function lastMessageId() {
-		return lastMessage?.serverId;
-	}
-
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function markReadUpTo(message: ChatMessage) {
 		markReadUpToMessage(message).then(_ -> {
@@ -2272,6 +2367,7 @@ class SerializedChat {
 	public final isBookmarked:Bool;
 	public final avatarSha1:Null<BytesData>;
 	public final presence:Map<String, Presence>;
+	public final membersForName: Null<Array<{id: String, displayName: String}>>;
 	public final displayName:Null<String>;
 	public final uiState:UiState;
 	public final isBlocked:Bool;
@@ -2279,23 +2375,25 @@ class SerializedChat {
 	public final extensions:String;
 	public final readUpToId:Null<String>;
 	public final readUpToBy:Null<String>;
-	public final threads: StringMapNullableKey = new StringMapNullableKey();
+	public final threads: StringMapNullableKey<String> = new StringMapNullableKey();
 	public final disco:Null<Caps>;
+	public final mavUntil:Null<String>;
 	public final omemoContactDeviceIDs: Array<Int>;
-	public final klass:String;
 	public final notificationsFiltered: Null<Bool>;
 	public final notifyMention: Bool;
 	public final notifyReply: Bool;
+	public final klass:String;
 
 	/**
 		Create a serialized chat snapshot suitable for persistence.
 	**/
-	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, status: Status, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) {
+	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, status: Status, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey<String>, disco: Null<Caps>, mavUntil: Null<String>, omemoContactDeviceIDs: Array<Int>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.isBookmarked = isBookmarked;
 		this.avatarSha1 = avatarSha1;
 		this.presence = presence;
+		this.membersForName = membersForName;
 		this.displayName = displayName;
 		this.uiState = uiState ?? Open;
 		this.isBlocked = isBlocked ?? false;
@@ -2308,6 +2406,7 @@ class SerializedChat {
 		this.notifyReply = notifyReply;
 		this.threads = threads;
 		this.disco = disco;
+		this.mavUntil = mavUntil;
 		this.omemoContactDeviceIDs = omemoContactDeviceIDs;
 		this.klass = klass;
 	}
@@ -2329,7 +2428,9 @@ class SerializedChat {
 			new DirectChat(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs);
 		} else if (klass == "Channel") {
 			final channel = new Channel(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensionsStanza, readUpToId, readUpToBy);
-			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"], []);
+			channel.membersForName = membersForName;
+			channel.mavUntil = mavUntil;
+			if (disco != null) channel.disco = client.capsRepo.add(disco, false);
 			if (notificationsFiltered == null && !channel.isPrivate()) {
 				mention = filterN = true;
 			}
@@ -2343,7 +2444,7 @@ class SerializedChat {
 		chat.status = status;
 		chat.setTrusted(trusted);
 		for (resource => p in presence) {
-			chat.setPresence(resource, p);
+			chat.setPresence(resource, p, true);
 		}
 		for (threadId => subject in threads) {
 			chat.setThreadSubject(threadId, subject);
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index c84681a..3730064 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -411,7 +411,7 @@ class ChatMessage {
 
 		@param sender optionally specify the full details of the sender
 	**/
-	public function body(sender: Null<Participant> = null):Html {
+	public function body(sender: Null<Member> = null):Html {
 		return new Html(htmlBody(), sender);
 	}
 
@@ -440,6 +440,14 @@ class ChatMessage {
 		return (!isIncoming() ? from?.asBare()?.asString() : to?.asBare()?.asString()) ?? throw "from or to is null";
 	}
 
+	/**
+		A basic Member for the sender, in case the full one can't be loaded
+	**/
+	public function senderMemberStub():Member {
+		final displayName = (type == MessageChannel ? from.resource : from.asString()) ?? " ";
+		return new Member(senderId, displayName, null, !isIncoming(), [], from, new Map(), null);
+	}
+
 	/**
 		Is this message the same as or a replacement for some other one?
 	**/
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 113f365..3b5eada 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -358,12 +358,9 @@ class Client extends EventEmitter {
 
 				if (presence.ver == null) {
 					chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza);
-					persistence.storeChats(this.accountId(), [chat]);
-					if (chat.livePresence()) this.trigger("chats/update", [chat]);
 				} else {
 					final handleCaps = (caps) -> {
 						chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza);
-						if (presence.mucUser == null || chat.livePresence()) persistence.storeChats(this.accountId(), [chat]);
 						return chat;
 					};
 
@@ -378,12 +375,12 @@ class Client extends EventEmitter {
 									final chatsToUpdate: Map<String, Chat> = [];
 									final handlers = pendingCaps.get(ver) ?? [];
 									pendingCaps.remove(ver);
-									if (discoGet.getResult() != null) capsRepo.add(discoGet.getResult());
-									for (handler in handlers) {
-										final c = handler(discoGet.getResult());
-										if (c.livePresence()) chatsToUpdate.set(c.chatId, c);
+									if (discoGet.getResult() != null) {
+										final cachedCaps = capsRepo.add(discoGet.getResult());
+										for (handler in handlers) {
+											final c = handler(cachedCaps);
+										}
 									}
-									this.trigger("chats/update", Lambda.array({ iterator: () -> chatsToUpdate.iterator() }));
 								});
 								sendQuery(discoGet);
 							} else {
@@ -429,9 +426,7 @@ class Client extends EventEmitter {
 					trace("Presence for unknown JID: " + stanza.attr.get("from"));
 					return EventUnhandled;
 				}
-				// Maybe in the future record it as offine rather than removing it
-				chat.removePresence(JID.parse(stanza.attr.get("from")).resource);
-				persistence.storeChats(this.accountId(), [chat]);
+				chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza);
 				this.trigger("chats/update", [chat]);
 			}
 
@@ -494,18 +489,19 @@ class Client extends EventEmitter {
 				if (chat != null) {
 					final updateChat = (chatMessage: ChatMessage) -> {
 						final eventType = chatMessage.versions.length > 1 ? CorrectionEvent : DeliveryEvent;
-						if (chat.lastMessage == null || eventType == DeliveryEvent || chatMessage.canReplace(chat.lastMessage)) {
-							chat.setLastMessage(chatMessage);
-						}
-
-						notifyMessageHandlers(chatMessage, eventType);
-
-						if (eventType == DeliveryEvent) {
-							chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
-							chatActivity(chat);
-						} else if (newChat != null) {
-							this.trigger("chats/update", [newChat]);
-						}
+						((chat.lastMessage == null || eventType == DeliveryEvent || chatMessage.canReplace(chat.lastMessage)) ?
+							chat.setLastMessage(chatMessage) :
+							Promise.resolve(null)
+						).then(_ -> {
+							notifyMessageHandlers(chatMessage, eventType);
+
+							if (eventType == DeliveryEvent) {
+								chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
+								chatActivity(chat);
+							} else if (newChat != null) {
+								this.trigger("chats/update", [newChat]);
+							}
+						});
 					};
 					if (chatMessage.serverId == null) {
 						updateChat(chatMessage);
@@ -599,6 +595,7 @@ class Client extends EventEmitter {
 		}
 #end
 
+		final chat = getChat(from.asBare().asString());
 		final chatState = stanza.getChild(null, "http://jabber.org/protocol/chatstates");
 		final userState = switch (chatState?.name) {
 			case "active": UserState.Active;
@@ -609,12 +606,39 @@ class Client extends EventEmitter {
 			default: null;
 		};
 		if (userState != null) {
-			final chat = getChat(from.asBare().asString());
-			if (chat == null || !chat.getParticipantDetails(message.senderId).isSelf) {
-				this.trigger("chat-state/update", { message: message, userState: userState });
+			if (chat == null) {
+				chat.getMemberDetails([message.senderId]).then(members -> {
+					if (members.length > 0 && members[0].isSelf) return;
+
+					this.trigger("chat-state/update", { message: message, userState: userState });
+				});
 			}
 		}
 
+		final memberUpdates = MemberUpdate.extractUpdates(accountId(), chat, stanza);
+		if (memberUpdates.length > 0) {
+trace("YYZZXX memberUpdates", chat.chatId, memberUpdates.length);
+			final channel = Util.downcast(chat, Channel);
+			final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user");
+			var isFullList = false;
+			if (channel != null && mucUser != null) {
+				final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1");
+				if (mav != null && mav.attr.get("since") == null) {
+					isFullList = true;
+				} else if (mav != null && mav.attr.get("since") != channel.mavUntil) {
+					trace("MAV update with unknown previous version", channel.mavUntil, stanza);
+				}
+				if (channel.mavUntil != mav?.attr?.get("until")) {
+					channel.mavUntil = mav?.attr?.get("until");
+					persistence.storeChats(accountId(), [channel]);
+				}
+			}
+
+			persistence.storeMemberUpdates(accountId(), chat, memberUpdates, isFullList).then(members -> {
+				if (members.length > 0) chat.trigger("members/update", members);
+			});
+		}
+
 		checkForReceipts(stanza);
 
 		final pubsubEvent = PubsubEvent.fromStanza(stanza);
@@ -760,6 +784,7 @@ class Client extends EventEmitter {
 
 			return persistence.getChats(accountId());
 		}).then((protoChats) -> {
+			protoChats.sort((a, b) -> a.chatId == accountId() ? 1 : 0);
 			var oneProtoChat = null;
 			while ((oneProtoChat = protoChats.pop()) != null) {
 				chats.push(oneProtoChat.toChat(this, stream, persistence));
@@ -921,6 +946,12 @@ class Client extends EventEmitter {
 			return EventHandled;
 		}
 
+		// We don't know what any presences are
+		persistence.clearMemberPresence(accountId(), null);
+		for (chat in chats) {
+			final directChat = Util.downcast(chat, DirectChat);
+			if (directChat != null) directChat.presence.clear();
+		}
 
 		discoverServices(new JID(null, jid.domain), (service, caps) -> {
 			persistence.storeService(accountId(), service.jid.asString(), service.name, service.node, caps);
@@ -943,10 +974,10 @@ class Client extends EventEmitter {
 				persistence.getChatsUnreadDetails(accountId(), chats).then((details) -> {
 					for (detail in details) {
 						var chat = getChat(detail.chatId) ?? getDirectChat(detail.chatId, false);
-						final initialLastId = chat.lastMessageId();
+						final initialLast = chat.lastMessage;
 						if (detail.message != null) chat.setLastMessage(detail.message);
 						chat.setUnreadCount(detail.unreadCount);
-						if (detail.unreadCount > 0 && initialLastId != chat.lastMessageId()) {
+						if (detail.unreadCount > 0 && initialLast != null && !initialLast.canReplace(chat.lastMessage)) {
 							chatActivity(chat, false);
 						}
 					}
@@ -1047,21 +1078,21 @@ class Client extends EventEmitter {
 
 		for (chat in chats) {
 			if (chat.isTrusted()) {
-				final resources:Map<String, Bool> = new Map();
-				for (resource in Caps.withIdentity(chat.getCaps(), "gateway", null)) {
+				final resources:StringMapNullableKey<Caps> = new StringMapNullableKey();
+				for (resource => caps in Caps.withIdentity(chat.getCaps(), "gateway", null)) {
 					// Sometimes gateway items also have id "gateway" for whatever reason
-					final identities = chat.getResourceCaps(resource)?.identities ?? [];
+					final identities = caps.identities;
 					if (
 						(chat.chatId.indexOf("@") < 0 || identities.find(i -> i.category == "conference") == null) &&
 						identities.find(i -> i.category == "client") == null
 					) {
-						resources[resource] = true;
+						resources[resource] = caps;
 					}
 				}
-				if (!sendAvailable && JID.parse(chat.chatId).isDomain()) {
-					resources[null] = true;
+				if (!resources.exists(null) && !sendAvailable && JID.parse(chat.chatId).isDomain()) {
+					resources[null] = CapsRepo.empty;
 				}
-				for (resource in resources.keys()) {
+				for (resource => caps in resources) {
 					final bareJid = JID.parse(chat.chatId);
 					final fullJid = new JID(bareJid.node, bareJid.domain, bareJid.isDomain() && resource == "" ? null : resource);
 					final jigGet = new JabberIqGatewayGet(fullJid.asString());
@@ -1069,7 +1100,7 @@ class Client extends EventEmitter {
 						jigGet.onFinished(() -> {
 							final result = jigGet.getResult();
 							if (result == null) {
-								final identity = chat.getResourceCaps(resource).identities[0];
+								final identity = caps.identities[0];
 								if (identity == null) {
 									resolve("");
 								} else {
@@ -1709,7 +1740,7 @@ class Client extends EventEmitter {
 			for (item in itemsGet.getResult() ?? []) {
 				final infoGet = new DiscoInfoGet(item.jid.asString(), item.node);
 				infoGet.onFinished(() -> {
-					callback(item, infoGet.getResult() ?? new Caps("", [], [], []));
+					callback(item, infoGet.getResult() ?? CapsRepo.empty);
 				});
 				sendQuery(infoGet);
 			}
@@ -1778,9 +1809,9 @@ class Client extends EventEmitter {
 					this.trigger("chats/update", [chat]);
 				}
 			} else {
-				capsRepo.add(resultCaps);
-				if (resultCaps.isChannel(jid)) {
-					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, false, null, resultCaps);
+				final cachedCaps = capsRepo.add(resultCaps);
+				if (cachedCaps.isChannel(jid)) {
+					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, false, null, cachedCaps);
 					chat.setupNotifications();
 					chats.unshift(chat);
 					if (inSync && sendAvailable) chat.selfPing(false);
@@ -1849,7 +1880,7 @@ class Client extends EventEmitter {
 					if (chat == null) {
 						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);
+						chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"), false).then(_ -> null, e -> e != null ? Promise.reject(e) : null);
 						chatsToUpdate.push(chat);
 					}
 				}
diff --git a/borogove/Html.hx b/borogove/Html.hx
index 3ef6d0e..668c95d 100644
--- a/borogove/Html.hx
+++ b/borogove/Html.hx
@@ -42,10 +42,10 @@ class Html {
 
 	@:allow(borogove)
 	private final xml: ReadOnlyArray<Node>;
-	private final sender: Null<Participant>;
+	private final sender: Null<Member>;
 
 	@:allow(borogove)
-	private function new(xml: Array<Node>, sender: Null<Participant>) {
+	private function new(xml: Array<Node>, sender: Null<Member>) {
 		this.xml = xml;
 		this.sender = sender;
 	}
diff --git a/borogove/Participant.hx b/borogove/Member.hx
similarity index 63%
rename from borogove/Participant.hx
rename to borogove/Member.hx
index 661eeae..dff9e17 100644
--- a/borogove/Participant.hx
+++ b/borogove/Member.hx
@@ -4,6 +4,7 @@ import thenshim.Promise;
 import haxe.ds.ReadOnlyArray;
 
 import borogove.Chat;
+import borogove.Presence;
 import borogove.queries.PubsubGet;
 
 #if cpp
@@ -16,14 +17,19 @@ import HaxeCBridge;
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
 #end
-class Participant {
+class Member {
 	/**
-		Display name to show for this participant
+		A unique id for this member
+	**/
+	public final id: String;
+
+	/**
+		Display name to show for this member
 	**/
 	public final displayName: String;
 
 	/**
-		Avatar URI for this participant, or null when none is known
+		Avatar URI for this member, or null when none is known
 	**/
 	public final photoUri: Null<String>;
 
@@ -33,38 +39,55 @@ class Participant {
 	public final placeholderUri: String;
 
 	/**
-		True when this participant is the connected account
+		True when this member is the connected account
 	**/
 	public final isSelf: Bool;
 
 	/**
-		Chat metadata for this participant when it is available as a direct Chat
+		Chat metadata for this member when it is available as a direct Chat
 	**/
 	public final chat: Null<AvailableChat>;
 
 	/**
-		Roles this participant has in the Chat
+		Roles this member has in the Chat
 	**/
 	public final roles: ReadOnlyArray<Role>;
 
+	public final showPresence: ShowPresence;
+
+	@:allow(borogove)
+	private var presence: Map<String, Presence>;
+
+	@:allow(borogove.MemberUpdate)
 	private final jid: JID;
 
 	@:allow(borogove)
-	private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, roles: Array<Role>, jid: JID, chat: Null<AvailableChat>) {
+	private function new(id: String, displayName: String, photoUri: Null<String>, isSelf: Bool, roles: Array<Role>, jid: JID, presence: Map<String, Presence>, chat: Null<AvailableChat>) {
+		this.id = id;
 		this.displayName = displayName;
 		this.photoUri = photoUri;
-		this.placeholderUri = placeholderUri;
+		this.placeholderUri = Color.defaultPhoto(id, displayName.charAt(0).toUpperCase());
 		this.isSelf = isSelf;
 		this.roles = roles;
-		this.chat = chat;
 		this.jid = jid;
+		this.presence = presence;
+		this.chat = chat;
+		var lowestShow = Offline;
+		var highestPrio = -100000;
+		for (_ => p in presence) {
+			if (p.priority >= highestPrio) {
+				highestPrio = p.priority;
+				if ((lowestShow : Int) > (p.show : Int)) lowestShow = p.show;
+			}
+		}
+		showPresence = lowestShow;
 	}
 
 	/**
-		Load the participant's profile
+		Load the member's profile
 
 		@param client connected client used to send the profile query
-		@returns Promise resolving to the participant profile
+		@returns Promise resolving to the member Profile
 	**/
 	public function profile(client: Client): Promise<Profile> {
 		return new Promise((resolve, reject) -> {
@@ -83,10 +106,10 @@ class Participant {
 	}
 
 	/**
-		Load the participant's status
+		Load the member's status
 
 		@param client connected client used to send the profile query
-		@returns Promise resolving to the participant status
+		@returns Promise resolving to the member Status
 	**/
 	public function status(client: Client): Promise<Status> {
 		return new Promise((resolve, reject) -> {
diff --git a/borogove/MemberUpdate.hx b/borogove/MemberUpdate.hx
new file mode 100644
index 0000000..70d532b
--- /dev/null
+++ b/borogove/MemberUpdate.hx
@@ -0,0 +1,93 @@
+package borogove;
+
+import thenshim.Promise;
+import haxe.ds.ReadOnlyArray;
+
+import borogove.Chat;
+import borogove.queries.PubsubGet;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+// Some updates are partial
+// We encode the more low-level values here
+@:nullSafety(Strict)
+class MemberUpdate {
+	public final id: Null<String>;
+	public final jid: Null<JID>;
+	private final displayName: Null<String>;
+	private final isSelf: Bool;
+	private final affiliation: Null<Role>; // Null means "member"
+	private final presence: Map<String, Presence>;
+
+	@:allow(borogove)
+	private function new(id: Null<String>, jid: Null<JID>, displayName: Null<String>, isSelf: Bool, affiliation: Null<Role>, presence: Map<String, Presence>) {
+		this.id = id;
+		this.jid = jid;
+		this.displayName = displayName;
+		this.isSelf = isSelf;
+		this.affiliation = affiliation;
+		this.presence = presence;
+	}
+
+	public function applyTo(member: Null<Member>) {
+		if (id != null || jid != null) {
+			if (member != null && member?.id != id && member?.chat?.chatId != jid?.asString()) throw "Member does not match this update";
+		}
+		if (member != null && isSelf != member?.isSelf) throw "Member does not match this update";
+		final filteredRoles = affiliation?.id == "none" ? [] : (member?.roles ?? []).filter(r -> !["owner", "admin", "member", "none", "outcast"].contains(r.id));
+		final mergedPresence = new Map();
+
+		for (resource => p in member?.presence ?? new Map()) {
+			mergedPresence.set(resource, p);
+		}
+
+		for (resource => p in presence) {
+			mergedPresence.set(resource, p);
+		}
+
+		return {
+			id: id ?? member?.id,
+			displayName: displayName ?? (member?.displayName == "" ? null : member?.displayName) ?? jid?.asString(),
+			photoUri: member?.photoUri,
+			isSelf: isSelf,
+			roles: filteredRoles.concat(affiliation == null ? [] : [affiliation]),
+			jid: member?.jid ?? jid,
+			presence: mergedPresence,
+			chat: jid == null ? null : new AvailableChat(jid.asBare().asString(), displayName, "", CapsRepo.empty)
+		};
+	}
+
+	static public function extractUpdates(accountId: String, chat: Chat, stanza: Stanza) {
+		final updates = [];
+		final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user");
+		if (mucUser != null) {
+			final sOccupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
+			final from = stanza.attr.get("from");
+			final resource = stanza.name == "presence" && from != null ? JID.parse(from).resource : null;
+			final channel = Util.downcast(chat, Channel);
+			for (item in mucUser.allTags("item")) {
+				final jidS = item.attr.get("jid");
+				final jid = jidS == null ? null : JID.parse(jidS).asBare();
+				final occupantId: Null<String> = item.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id") ?? sOccupantId;
+				final id = occupantId == null ? null : chat.chatId + "/" + occupantId;
+				final aff = item.attr.get("affiliation");
+				if (aff == null) {
+					trace("No affiliation on affiliation update", stanza);
+				} else {
+					updates.push(new MemberUpdate(
+						id,
+						jid,
+						item.attr.get("nick") ?? resource,
+						jid?.asString() == accountId || channel?.self?.id == id,
+						Role.forAffiliation(aff),
+						stanza.name == "presence" ? [ resource ?? "" => stanza ] : new Map()
+					));
+				}
+			}
+		}
+
+		return updates;
+	}
+}
diff --git a/borogove/Message.hx b/borogove/Message.hx
index 29f59ea..07bf735 100644
--- a/borogove/Message.hx
+++ b/borogove/Message.hx
@@ -88,7 +88,8 @@ class Message {
 		if (msg.type == MessageChat && stanza.getChild("x", "http://jabber.org/protocol/muc#user") != null) {
 			msg.type = MessageChannelPrivate;
 		}
-		msg.senderId = (msg.type == MessageChannel || msg.type == MessageChannelPrivate ? msg.from : msg.from?.asBare())?.asString();
+		final occupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
+		msg.senderId = occupantId != null ? msg.chatId() + "/" + occupantId : (msg.type == MessageChannel || msg.type == MessageChannelPrivate ? msg.from : msg.from?.asBare())?.asString();
 		final localJidBare = localJid.asBare();
 		final domain = localJid.domain;
 		final to = stanza.attr.get("to");
@@ -344,6 +345,7 @@ class Message {
 				final replyToMessage = new ChatMessageBuilder();
 				replyToMessage.to = replyToJid == msg.senderId ? msg.to : msg.from;
 				replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid);
+				// Note: in MUC this senderid is wrong and not an occupant-id
 				replyToMessage.senderId = isGroupchat ? replyToMessage.from?.asString() : replyToMessage.from?.asBare()?.asString();
 				replyToMessage.replyId = replyToID;
 				if ((msg.serverIdBy != null && msg.serverIdBy != localJid.asBare().asString()) || isGroupchat) {
diff --git a/borogove/Persistence.hx b/borogove/Persistence.hx
index e81d410..6695130 100644
--- a/borogove/Persistence.hx
+++ b/borogove/Persistence.hx
@@ -47,6 +47,61 @@ interface Persistence {
 	@HaxeCBridge.noemit
 	public function getChats(accountId: String): Promise<Array<SerializedChat>>;
 
+	/**
+		Persist some members for a chat
+
+		@param accountId the account that owns the chat
+		@param chatId the chat whose members are being stored
+		@param members members to write to storage
+		@returns Promise resolving true when the write completes
+	**/
+	@HaxeCBridge.noemit
+	public function storeMembers(accountId: String, chatId: String, members: Array<Member>): Promise<Bool>;
+
+	/**
+		Apply one or more member updates to stored chat membership state
+
+		@param accountId the account that owns the chat
+		@param chat the chat whose membership is being updated
+		@param updates incremental member updates to apply
+		@param isFullList true when updates represent the complete current affiliation list
+		@returns Promise resolving to the updated members that should be surfaced to callers
+	**/
+	@HaxeCBridge.noemit
+	public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool): Promise<Array<Member>>;
+
+	/**
+		Clear cached member presence for one chat or an entire account
+
+		@param accountId the account whose cached presence should be cleared
+		@param chatId the chat to clear, or null to clear every chat in the account
+		@returns Promise resolving true when the clear completes
+	**/
+	@HaxeCBridge.noemit
+	public function clearMemberPresence(accountId: String, chatId: Null<String>): Promise<Bool>;
+
+	/**
+		Load some members for a chat
+
+		@param accountId the account that owns the chat
+		@param chat the chat to load members for
+		@param forModerator true to include moderator-only rows such as banned occupants
+		@returns Promise resolving to the members visible for the requested view
+	**/
+	@HaxeCBridge.noemit
+	public function getMembers(accountId: String, chat: Chat, forModerator: Bool): Promise<Array<Member>>;
+
+	/**
+		Load detailed member records by ID
+
+		@param accountId the account that owns the member records
+		@param chat the chat context for any hydrated chat-specific metadata, or null when unavailable
+		@param ids member IDs to look up
+		@returns Promise resolving to an entry per requested ID, with null for unknown or incomplete members
+	**/
+	@HaxeCBridge.noemit
+	public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>): Promise<Array<Null<Member>>>;
+
 	/**
 		Load unread counters and most recent unread message per Chat
 
diff --git a/borogove/Presence.hx b/borogove/Presence.hx
index 9ab8b52..08206b6 100644
--- a/borogove/Presence.hx
+++ b/borogove/Presence.hx
@@ -4,6 +4,14 @@ import borogove.Hash;
 import borogove.MucUser;
 import borogove.Role;
 
+enum abstract ShowPresence(Int) to Int {
+	var Online;
+	var Idle;
+	var DoNotDisturb;
+	var Standby;
+	var Offline;
+}
+
 @:nullSafety(StrictThreaded)
 @:forward(toString)
 @:expose
@@ -13,6 +21,9 @@ abstract Presence(Stanza) from Stanza to Stanza {
 	public var mucUser(get, never): Null<MucUser>;
 	public var hats(get, never): Null<Array<Role>>;
 	public var avatarHash(get, never): Null<Hash>;
+	public var type(get, never): Null<String>;
+	public var show(get, never): ShowPresence;
+	public var priority(get, never): Int;
 
 	/**
 		Create a presence stanza wrapper from caps, MUC metadata, and avatar hash.
@@ -50,4 +61,23 @@ abstract Presence(Stanza) from Stanza to Stanza {
 		final avatarSha1Hex = this.findText("{vcard-temp:x:update}x/photo#");
 		return avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex);
 	}
+
+	private inline function get_type() {
+		return this.attr.get("type");
+	}
+
+	private inline function get_show() {
+		if (type == "unavailable") return Offline;
+
+		return switch (this.getChildText("show")) {
+			case "away": Idle;
+			case "dnd": DoNotDisturb;
+			case "xa": Standby;
+			default: Online;
+		};
+	}
+
+	private inline function get_priority() {
+		return Std.parseInt(this.getChildText("priority") ?? "0") ?? 0;
+	}
 }
diff --git a/borogove/Role.hx b/borogove/Role.hx
index cb20035..50307c3 100644
--- a/borogove/Role.hx
+++ b/borogove/Role.hx
@@ -10,7 +10,7 @@ import borogove.Color;
 #end
 class Role {
 	// A role is the unification of XMPP affiliations and hats
-	// importantly, it is *not* and XMPP MUC role
+	// importantly, it is *not* an XMPP MUC role
 
 	/**
 		Unique id for the role
@@ -32,9 +32,9 @@ class Role {
 	private static function forAffiliation(aff: String) {
 		final title = switch (aff) {
 			case "outcast": "Banned";
-			case "member": "Member";
 			case "admin": "Admin";
 			case "owner": "Owner";
+			case "none": "Guest";
 			default: return null;
 		}
 		return new Role(aff, title);
diff --git a/borogove/Util.hx b/borogove/Util.hx
index bbe8d27..66e8ccb 100644
--- a/borogove/Util.hx
+++ b/borogove/Util.hx
@@ -193,6 +193,10 @@ class Util {
 		o.writeBytes(b, 0, b.length);
 	}
 
+	inline static public function filterOutNulls<A>(arr: Array<Null<A>>, f: (A -> Bool)): Array<A> {
+		return arr.filter((x) -> x != null && f(x));
+	}
+
 	/**
 		Convert a String index to a UnicodeString index
 	**/
diff --git a/borogove/persistence/Dummy.hx b/borogove/persistence/Dummy.hx
index 4fc55aa..e23f041 100644
--- a/borogove/persistence/Dummy.hx
+++ b/borogove/persistence/Dummy.hx
@@ -42,6 +42,31 @@ class Dummy implements Persistence {
 		return Promise.resolve([]);
 	}
 
+	@HaxeCBridge.noemit
+	public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) {
+		return Promise.resolve(false);
+	}
+
+	@HaxeCBridge.noemit
+	public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) {
+		return Promise.resolve([]);
+	}
+
+	@HaxeCBridge.noemit
+	public function clearMemberPresence(accountId: String, chatId: Null<String>) {
+		return Promise.resolve(false);
+	}
+
+	@HaxeCBridge.noemit
+	public function getMembers(accountId: String, chat: Chat, forModerator: Bool) {
+		return Promise.resolve([]);
+	}
+
+	@HaxeCBridge.noemit
+	public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) {
+		return Promise.resolve([]);
+	}
+
 	@HaxeCBridge.noemit
 	public function storeMessages(accountId: String, messages: Array<ChatMessage>): Promise<Array<ChatMessage>> {
 		return Promise.resolve(messages);
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 1463399..95cc2b5 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -3,6 +3,7 @@
 
 // Importing internals not the public interface
 import {
+	borogove_AvailableChat,
 	borogove_Caps,
 	borogove_Channel,
 	borogove_ChatMessageBuilder,
@@ -10,6 +11,8 @@ import {
 	borogove_DirectChat,
 	borogove_Hash,
 	borogove_Identity,
+	borogove_Member,
+	borogove_Role,
 	borogove_JID,
 	borogove_Presence,
 	borogove_Reaction,
@@ -152,6 +155,15 @@ export default async (dbname, media, tokenize, stemmer) => {
 				if (!db.objectStoreNames.contains("chats")) {
 					db.createObjectStore("chats", { keyPath: ["account", "chatId"] });
 				}
+				if (!db.objectStoreNames.contains("members")) {
+					const members = db.createObjectStore("members", { keyPath: ["account", "id"] });
+				}
+				if (tx.objectStore("members").indexNames.contains("chats")) {
+					tx.objectStore("members").deleteIndex("chats");
+				}
+				if (!tx.objectStore("members").indexNames.contains("chatsWithTrueJid")) {
+					tx.objectStore("members").createIndex("chatsWithTrueJid", ["account", "chatId", "isSelf", "chat"]);
+				}
 				if (!db.objectStoreNames.contains("services")) {
 					db.createObjectStore("services", { keyPath: ["account", "serviceId"] });
 				}
@@ -192,14 +204,15 @@ export default async (dbname, media, tokenize, stemmer) => {
 			dbOpenReq.onsuccess = (event) => {
 				const db = event.target.result;
 				const storeNames = [
-					"messages",
-					"keyvaluepairs",
 					"chats",
-					"services",
-					"reactions",
+					"keyvaluepairs",
+					"members",
+					"messages",
 					"omemo_identities",
 					"omemo_sessions",
-					"omemo_sessions_meta"
+					"omemo_sessions_meta",
+					"reactions",
+					"services",
 				];
 				for(const storeName of storeNames) {
 					if(!db.objectStoreNames.contains(storeName)) {
@@ -208,7 +221,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 						return;
 					}
 				}
-				const tx = db.transaction(["messages", "keyvaluepairs"], "readonly");
+				const tx = db.transaction(["messages", "members", "keyvaluepairs"], "readonly");
 				const messagesIndexNames = tx.objectStore("messages").indexNames;
 				const wantIndexNames = ["chatsBySortId", "accountsBySortId", "terms", "chats"];
 				for(const indexName of wantIndexNames) {
@@ -219,6 +232,12 @@ export default async (dbname, media, tokenize, stemmer) => {
 					}
 				}
 
+				if (!tx.objectStore("members").indexNames.contains("chatsWithTrueJid")) {
+					db.close();
+					openDb(db.version + 1).then(resolve, reject);
+					return;
+				}
+
 				(async () => {
 					const kv = tx.objectStore("keyvaluepairs");
 					const ranMigrationAddSortIdAndTerms = await promisifyRequest(kv.get("__migrationAddSortIdAndTerms"));
@@ -253,6 +272,19 @@ export default async (dbname, media, tokenize, stemmer) => {
 		});
 	}
 
+	function hydrateMember(chat, raw) {
+		return new borogove_Member(
+			raw.id,
+			raw.displayName,
+			raw.photoUri,
+			raw.isSelf ? true : false,
+			raw.roles.map(role => new borogove_Role(role.id, role.title)),
+			raw.jid instanceof borogove_JID ? raw.jid : borogove_JID.parse(raw.jid),
+			new Map((raw.presence?.entries() ?? []).map(([k, p]) => [k, p instanceof borogove_Stanza ? p : borogove_Stanza.parse(p)])),
+			raw.chat ? new borogove_AvailableChat(raw.chat, raw.displayName, raw.chat + (chat ? " (via " + chat.getDisplayName() + ")" : ""), new borogove_Caps("", [], [], [])) : null
+		);
+	}
+
 	function hydrateStringReaction(r, senderId, timestamp) {
 		if (r.startsWith("ni://")){
 			return new borogove_CustomEmojiReaction(senderId, timestamp, "", r);
@@ -409,6 +441,37 @@ export default async (dbname, media, tokenize, stemmer) => {
 		return reactionsMap;
 	}
 
+	async function chatPresenceAndMembersForName(account, store, rawChat) {
+		if (rawChat.class == "DirectChat") {
+			return [new Map(((await promisifyRequest(store.get([account, rawChat.chatId])))?.presence?.entries() ?? []).map(([k, p]) => [k, borogove_Stanza.parse(p)])), null];
+		}
+
+		const range = IDBKeyRange.bound([account, rawChat.chatId], [account, rawChat.chatId, []]);
+		const cursor = store.index("chatsWithTrueJid").openCursor(range, "prev");
+		let membersForName = [];
+		let presence = new Map();
+		while (true) {
+			const cresult = await promisifyRequest(cursor);
+			if (!cresult?.value) break;
+
+			if (cresult.value.isSelf) {
+				presence = new Map((cresult.value.presence?.entries() ?? []).map(([k, p]) => [k, borogove_Stanza.parse(p)]));
+			} else if (!cresult.value.roles.find(r => ["none", "outcast"].includes(r.id)) && cresult.value.id !== rawChat.chatId) {
+				membersForName.push({ id: cresult.value.id, displayName: cresult.value.displayName });
+			}
+
+			// In big rooms we don't make a name from the members
+			if (membersForName.length > 20) {
+				membersForName = null;
+				break;
+			}
+
+			cresult.continue();
+		}
+
+		return [presence, membersForName];
+	};
+
 	const obj = {
 		syncPoint: async function(account, chatId) {
 			const tx = db.transaction(["messages"], "readonly");
@@ -448,7 +511,6 @@ export default async (dbname, media, tokenize, stemmer) => {
 					trusted: chat.trusted,
 					isBookmarked: chat.isBookmarked,
 					avatarSha1: chat.avatarSha1,
-					presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, p.toString()])),
 					status: chat.status,
 					displayName: chat.displayName,
 					uiState: chat.uiState,
@@ -458,6 +520,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 					readUpToBy: chat.readUpToBy,
 					notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null,
 					threads: chat.threads,
+					mavUntil: chat.mavUntil,
 					disco: { ...chat.disco, data: chat.disco?.data?.map(d => d.toString()) },
 					omemoDevices: chat.omemoContactDeviceIDs,
 					class: chat instanceof borogove_DirectChat ? "DirectChat" : (chat instanceof borogove_Channel ? "Channel" : "Chat")
@@ -466,8 +529,9 @@ export default async (dbname, media, tokenize, stemmer) => {
 		},
 
 		getChats: async function(account) {
-			const tx = db.transaction(["chats"], "readonly");
+			const tx = db.transaction(["chats", "members"], "readonly");
 			const store = tx.objectStore("chats");
+			const membersStore = tx.objectStore("members");
 			const range = IDBKeyRange.bound([account], [account, []]);
 			const result = await promisifyRequest(store.getAll(range));
 			return await Promise.all(result.map(async (r) => new borogove_SerializedChat(
@@ -475,12 +539,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				r.trusted,
 				r.isBookmarked,
 				r.avatarSha1,
-				new Map(await Promise.all((r.presence instanceof Map ? [...r.presence.entries()] : Object.entries(r.presence)).map(
-					async ([k, p]) => [k,
-						typeof(p) === "string" ? borogove_Stanza.parse(p) :
-						borogove_Presence._new(p.caps && await this.getCaps(p.caps), p.mucUser && borogove_Stanza.parse(p.mucUser), p.avatarHash && borogove_Hash.fromUri(p.avatarHash))
-					]
-				))),
+				...await chatPresenceAndMembersForName(account, membersStore, r),
 				r.displayName,
 				r.uiState,
 				r.isBlocked,
@@ -498,11 +557,148 @@ export default async (dbname, media, tokenize, stemmer) => {
 					r.disco.features || [],
 					(r.disco.data || []).map(s => borogove_Stanza.parse(s))
 				) : null,
+				r.mavUntil,
 				r.omemoDevices || [],
 				r.class
 			)));
 		},
 
+		async storeMembers(account, chatId, members) {
+			const tx = db.transaction(["members"], "readwrite");
+			const store = tx.objectStore("members");
+
+			await Promise.all(members.map(member => promisifyRequest(store.put({
+				account,
+				chatId,
+				id: member.id,
+				displayName: member.displayName,
+				photoUri: member.photoUri,
+				isSelf: member.isSelf ? 1 : 0, // Can't index on boolean
+				chat: member.chat?.chatId ?? "",
+				roles: member.roles,
+				presence: new Map([...member.presence.entries()].map(([k, p]) => [k, p.toString()])),
+				jid: member.jid.asString(),
+			}))));
+
+			return true;
+		},
+
+		async storeMemberUpdates(account, chat, updates, isFullList) {
+			const tx = db.transaction(["members"], "readonly");
+			const store = tx.objectStore("members");
+			const updatesFor = new Set();
+
+			const pseudoMembers = await Promise.all(updates.map(async (update) => {
+				let member = null;
+				if (update.id) {
+					member = await promisifyRequest(store.get([account, update.id]));
+				}
+				if (update.jid && !member) {
+					member = await promisifyRequest(store.index("chatsWithTrueJid").get([account, chat.chatId, update.isSelf ? 1 : 0, update.jid.asString()]));
+				}
+				if (member?.id || update.id) updatesFor.add(update.id ?? member?.id);
+				return update.applyTo(member ? hydrateMember(chat, member) : null);
+			}));
+
+			await this.storeMembers(account, chat.chatId, pseudoMembers.filter(m => m?.id));
+
+			if (isFullList) {
+				const txW = db.transaction(["members"], "readwrite");
+				const storeW = txW.objectStore("members");
+				const range = IDBKeyRange.bound([account, chat.chatId], [account, chat.chatId, []]);
+				const cursor = storeW.index("chatsWithTrueJid").openCursor(range);
+				while (true) {
+					const cresult = await promisifyRequest(cursor);
+					if (!cresult?.value) break;
+
+					if (!updatesFor.has(cresult.value.id)) {
+						// No update for this member, and we have the full list
+						// So they are no longer an affiliated member
+						cresult.update({ ...cresult.value, roles: [] });
+					}
+
+					cresult.continue();
+				}
+			}
+
+			return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: chat?.chatId }));
+		},
+
+		async clearMemberPresence(account, chatId) {
+			const tx = db.transaction(["members"], "readwrite");
+			const store = tx.objectStore("members");
+			const range = IDBKeyRange.bound(chatId ? [account, chatId] : [account], chatId ? [account, chatId, []] : [account, []]);
+			const cursor = store.index("chatsWithTrueJid").openCursor(range);
+			while (true) {
+				const cresult = await promisifyRequest(cursor);
+				if (!cresult?.value) break;
+
+				cresult.update({ ...cresult.value, presence: new Map() });
+
+				cresult.continue();
+			}
+
+			return true;
+		},
+
+		async getMembers(account, chat, forModerator) {
+			const roleSort = { owner: 4, admin: 3, none: 1, outcast: 0 };
+			const tx = db.transaction(["members"], "readonly");
+			const store = tx.objectStore("members");
+			const range = IDBKeyRange.bound([account, chat.chatId], [account, chat.chatId, []]);
+			// getAll is much faster than openCursor,
+			// but if the room has more than 20k members this will miss people
+			// at which point we need paging
+			const allMembers = await promisifyRequest(store.index("chatsWithTrueJid").getAll(range, 20000));
+			let clearedOffline = false;
+			let result = [];
+			let member = null;
+			while ((member = allMembers.pop())) {
+				if (!member.id || !member.displayName || !member.jid) continue;
+				if (!forModerator && member.roles.find(r => r.id == "outcast")) continue;
+
+				const presenceKey = member.presence.keys().next()?.value;
+				const isOnline = presenceKey && !member.presence.get(presenceKey).includes('type="unavailable"');
+				if (member.roles.find(r => r.id == "none") && !isOnline) continue;
+
+				if (!forModerator && !clearedOffline && result.length >= 1000) {
+					result = result.filter(m => m.__isOnline);
+					clearedOffline = true;
+				}
+
+				if (!forModerator && clearedOffline && !isOnline) continue;
+
+				const hydrated = hydrateMember(chat, member);
+				hydrated.__isOnline = isOnline;
+				hydrated.__roleSort = hydrated.roles.length < 1 ? 2 : Math.max(...hydrated.roles.map(r => roleSort[r.id] ?? 2));
+				hydrated.__sortKey = hydrated.roles.map(r => r.title).sort().join(" ") + " " + hydrated.displayName;
+				result.push(hydrated);
+
+				if (!forModerator && result.length >= 2000) break;
+			}
+
+			const collator = new Intl.Collator(undefined, {
+				numeric: true,
+				sensitivity: "base",
+			});
+
+			return result.sort((x, y) => {
+				if (x.__roleSort !== y.__roleSort) return y.__roleSort - x.__roleSort;
+				return collator.compare(x.__sortKey, y.__sortKey);
+			});
+		},
+
+		async getMemberDetails(account, chat, ids) {
+			const tx = db.transaction(["members"], "readonly");
+			const store = tx.objectStore("members");
+			return await Promise.all(ids.map(async (id) => {
+				const raw = await promisifyRequest(store.get([account, id]));
+				if (!raw?.id || !raw?.displayName || !raw?.jid) return null;
+
+				return hydrateMember(chat, raw);
+			}));
+		},
+
 		getChatUnreadDetails: async function(account, chat) {
 			const tx = db.transaction(["messages"], "readonly");
 			const store = tx.objectStore("messages");
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 507708c..909ad82 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -286,9 +286,9 @@ class Sqlite implements Persistence implements KeyValueStore {
 		storeChatTimer = haxe.Timer.delay(() -> {
 			final mapPresence = (chat: Chat) -> {
 				final storePresence: DynamicAccess<String> = {};
-				for (resource => presence in chat.presence) {
+				/* TODO for (resource => presence in chat.presence) {
 					if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = presence.toString();
-				}
+				}*/
 				return storePresence;
 			};
 			final q = new StringBuf();
@@ -328,6 +328,36 @@ class Sqlite implements Persistence implements KeyValueStore {
 		}, 100);
 	}
 
+	@HaxeCBridge.noemit
+	public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) {
+		// TODO
+		return Promise.resolve(false);
+	}
+
+	@HaxeCBridge.noemit
+	public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) {
+		// TODO
+		return Promise.resolve([]);
+	}
+
+	@HaxeCBridge.noemit
+	public function clearMemberPresence(accountId: String, chatId: Null<String>) {
+		//  TODO
+		return Promise.resolve(false);
+	}
+
+	@HaxeCBridge.noemit
+	public function getMembers(accountId: String, chat: Chat, forModerator: Bool) {
+		//  TODO
+		return Promise.resolve([]);
+	}
+
+	@HaxeCBridge.noemit
+	public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) {
+		// TODO
+		return Promise.resolve([]);
+	}
+
 	@HaxeCBridge.noemit
 	public function searchMessages(accountId: String, chatId: Null<String>, q: String): Promise<Array<ChatMessage>> {
 		var sql = "SELECT stanza, direction, type, status, status_text, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sort_id, sync_point FROM messages WHERE account_id=? AND stanza LIKE ?";
@@ -364,13 +394,16 @@ class Sqlite implements Persistence implements KeyValueStore {
 				}
 
 				final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }> } = Json.parse(row.meta);
-				final threadsMap: StringMapNullableKey = new StringMapNullableKey();
+				final threadsMap: StringMapNullableKey<String> = new StringMapNullableKey();
 				for (thread => subject in metaJson.threads ?? {}) {
 					threadsMap.set(thread == "" ? null : thread, subject);
 				}
 
 				// FIXME: Empty OMEMO contact device ids hardcoded in next line
-				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, [], Reflect.field(row, "class")));
+				// TODO: memebersForName
+				// TODO: new presence storage
+				// TODO: mavUntil
+				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, [], row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, null, [], Reflect.field(row, "class")));
 			}
 			return chats;
 		});
diff --git a/npm/index.ts b/npm/index.ts
index 418776c..bee6fb7 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -31,7 +31,7 @@ export {
     borogove_Identicon as Identicon,
     borogove_LinkMetadata as LinkMetadata,
     borogove_Notification as Notification,
-    borogove_Participant as Participant,
+    borogove_Member as Member,
     borogove_Profile as Profile,
     borogove_ProfileBuilder as ProfileBuilder,
     borogove_ProfileItem as ProfileItem,
diff --git a/test/TestAll.hx b/test/TestAll.hx
index c68f0a1..91750a2 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -21,7 +21,8 @@ class TestAll {
 			new TestStanza(),
 			new TestPresence(),
 			new TestClient(),
-			new TestParticipant(),
+			new TestMember(),
+			new TestMemberUpdate(),
 			new TestChat(),
 			new TestSortId(),
 			new TestXEP0393(),
diff --git a/test/TestCaps.hx b/test/TestCaps.hx
index f9291ac..7c0c371 100644
--- a/test/TestCaps.hx
+++ b/test/TestCaps.hx
@@ -8,7 +8,7 @@ import borogove.Hash;
 import borogove.Stanza;
 import borogove.queries.DiscoInfoGet;
 
-@:access(borogove.Caps.hashInput)
+@:access(borogove.Caps)
 @:access(borogove.Hash.sha256)
 class TestCaps extends utest.Test {
 	final example = '
@@ -120,4 +120,144 @@ class TestCaps extends utest.Test {
 			s.toString()
 		);
 	}
+
+	function stableKVOrderIterator<K, V>(data:Array<{key:K, value:V}>):KeyValueIterator<K, V> {
+		var i = 0;
+		return {
+			hasNext: () -> i < data.length,
+			next: () -> data[i++]
+		};
+	}
+
+	public function testWithIdentityCategory() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", null);
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r1", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityType() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), null, "sms");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityBoth() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", "pc");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r1", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityNoMatch() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", "sms");
+		Assert.isFalse(iter.hasNext());
+
+		iter = Caps.withIdentity(stableKVOrderIterator(data), "other", null);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityEmpty() {
+		var iter = Caps.withIdentity(stableKVOrderIterator([]), "client", null);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityMatchLast() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), "gateway", null);
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithIdentityMatchMiddle() {
+		final c1 = new Caps("n1", [new Identity("client", "pc", "test")], [], []);
+		final c2 = new Caps("n2", [new Identity("gateway", "sms", "test")], [], []);
+		final data = [{key: "r1", value: c2}, {key: "r2", value: c1}, {key: "r3", value: c2}];
+
+		var iter = Caps.withIdentity(stableKVOrderIterator(data), "client", null);
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureMatch() {
+		final c1 = new Caps("n1", [], ["f1", "f2"], []);
+		final c2 = new Caps("n2", [], ["f2", "f3"], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withFeature(stableKVOrderIterator(data), "f1");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r1", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureMultiMatch() {
+		final c1 = new Caps("n1", [], ["f1", "f2"], []);
+		final c2 = new Caps("n2", [], ["f2", "f3"], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withFeature(stableKVOrderIterator(data), "f2");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r1", iter.next().key);
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureNoMatch() {
+		final c1 = new Caps("n1", [], ["f1", "f2"], []);
+		final c2 = new Caps("n2", [], ["f2", "f3"], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withFeature(stableKVOrderIterator(data), "f4");
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureEmpty() {
+		var iter = Caps.withFeature(stableKVOrderIterator([]), "f1");
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureMatchLast() {
+		final c1 = new Caps("n1", [], ["f1", "f2"], []);
+		final c2 = new Caps("n2", [], ["f2", "f3"], []);
+		final data = [{key: "r1", value: c1}, {key: "r2", value: c2}];
+
+		var iter = Caps.withFeature(stableKVOrderIterator(data), "f3");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
+
+	public function testWithFeatureMatchMiddle() {
+		final c1 = new Caps("n1", [], ["f1", "f2"], []);
+		final c2 = new Caps("n2", [], ["f2", "f3"], []);
+		final data = [{key: "r1", value: c2}, {key: "r2", value: c1}, {key: "r3", value: c2}];
+
+		var iter = Caps.withFeature(stableKVOrderIterator(data), "f1");
+		Assert.isTrue(iter.hasNext());
+		Assert.equals("r2", iter.next().key);
+		Assert.isFalse(iter.hasNext());
+	}
 }
diff --git a/test/TestCapsRepo.hx b/test/TestCapsRepo.hx
index ffe083f..2d1472f 100644
--- a/test/TestCapsRepo.hx
+++ b/test/TestCapsRepo.hx
@@ -28,6 +28,30 @@ class CapsRepoMockPersistence extends Dummy {
 }
 
 class TestCapsRepo extends utest.Test {
+	public function testAddDeduplicatesAndReturnsCached() {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final caps1 = new Caps("node1", [], ["feat1"], []);
+		final caps2 = new Caps("node1", [], ["feat1"], []);
+
+		final added1 = repo.add(caps1);
+		final added2 = repo.add(caps2);
+
+		Assert.equals(caps1, added1);
+		Assert.equals(caps1, added2);
+		Assert.equals(1, persistence.storedCaps.length);
+	}
+
+	public function testAddWithoutStoreOnMiss() {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final caps = new Caps("node1", [], ["feat1"], []);
+
+		repo.add(caps, false);
+
+		Assert.equals(0, persistence.storedCaps.length);
+	}
+
 	public function testAddAndGet(async: Async) {
 		final persistence = new CapsRepoMockPersistence();
 		final repo = new CapsRepo(persistence);
@@ -86,4 +110,14 @@ class TestCapsRepo extends utest.Test {
 			async.done();
 		}, 1);
 	}
+
+	public function testGetSyncMissingReturnsEmpty() {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final presence = Stanza.parse('<presence><c xmlns="http://jabber.org/protocol/caps" node="node1" ver="missing"/></presence>');
+
+		final retrieved = repo.get(presence);
+
+		Assert.equals(CapsRepo.empty, retrieved);
+	}
 }
diff --git a/test/TestChat.hx b/test/TestChat.hx
index c88e3c3..b197536 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -7,9 +7,13 @@ import borogove.ChatMessageBuilder;
 import borogove.Stanza;
 import borogove.JID;
 import borogove.persistence.Dummy;
+import borogove.CapsRepo;
 import borogove.Chat.Channel;
 import borogove.Chat.AvailableChat;
 import borogove.Caps.Identity;
+import borogove.Member;
+import borogove.Role;
+import thenshim.Promise;
 
 @:access(borogove)
 class TestChat extends utest.Test {
@@ -265,18 +269,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());
 
-		// Nick in use set
-		chat._nickInUse = "mynick";
+		chat.self = new Member("me", "myself", null, true, [], JID.parse("test@example.com"), new Map(), null);
 		Assert.isFalse(chat.canModerate());
 
 		// Presence set but not moderator
-		final p = new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null);
-		chat.presence.set("mynick", p);
+		chat.self = 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
-		final p2 = new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null);
-		chat.presence.set("mynick", p2);
+		chat.self = 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());
 	}
 
@@ -352,51 +361,32 @@ class TestChat extends utest.Test {
 		Assert.isTrue(builder.syncPoint, "Message SHOULD have syncPoint if inSync");
 	}
 
-	public function testGetParticipantDetailsWithRoles() {
+	public function testAvailableRoles() {
 		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);
 
-		final stanza = Stanza.parse('<presence from="channel@example.com/other">
-			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="admin" role="participant"/></x>
-			<hats xmlns="urn:xmpp:hats:0">
-				<hat uri="http://example.com/custom" title="Custom Role"/>
-			</hats>
-		</presence>');
-		chat.presence.set("other", stanza);
-
-		final details = chat.getParticipantDetails("channel@example.com/other");
-		Assert.equals(2, details.roles.length);
-		Assert.equals("admin", details.roles[0].id);
-		Assert.equals("Admin", details.roles[0].title);
-		Assert.equals("http://example.com/custom", details.roles[1].id);
-		Assert.equals("Custom Role", details.roles[1].title);
+		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);
+		Assert.contains("owner", ids);
+		Assert.contains("admin", ids);
+		Assert.contains("outcast", ids);
+		Assert.contains("none", ids);
 	}
 
-	public function testAvailableRoles() {
+	public function testAvailableRolesForNone() {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
-		chat._nickInUse = "me";
-
-		// I am owner
-		final myPresence = Stanza.parse('<presence from="channel@example.com/me">
-			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="owner" role="moderator"/></x>
-		</presence>');
-		chat.presence.set("me", myPresence);
-
-		// Other is member
-		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
-			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
-		</presence>');
-		chat.presence.set("other", otherPresence);
+		chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
 
-		final roles = chat.availableRoles("channel@example.com/other");
+		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);
 		Assert.contains("owner", ids);
 		Assert.contains("admin", ids);
 		Assert.contains("outcast", ids);
-		Assert.isFalse(ids.contains("member"), "Should not include current role");
+		Assert.isFalse(ids.contains("none"));
 	}
 
 	public function testAddRole(async: Async) {
@@ -404,11 +394,6 @@ class TestChat extends utest.Test {
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
 
-		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
-			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
-		</presence>');
-		chat.presence.set("other", otherPresence);
-
 		client.stream.on("sendStanza", (stanza: Stanza) -> {
 			if (stanza.name == "iq" && stanza.attr.get("type") == "set") {
 				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin");
@@ -423,7 +408,7 @@ class TestChat extends utest.Test {
 			return EventUnhandled;
 		});
 
-		chat.addRole("channel@example.com/other", borogove.Role.forAffiliation("admin"));
+		chat.addRole(new Member("other", "other", null, true, [], JID.parse("test@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty)), borogove.Role.forAffiliation("admin"));
 	}
 
 	public function testRemoveRole(async: Async) {
@@ -431,17 +416,12 @@ class TestChat extends utest.Test {
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
 
-		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
-			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
-		</presence>');
-		chat.presence.set("other", otherPresence);
-
 		client.stream.on("sendStanza", (stanza: Stanza) -> {
 			if (stanza.name == "iq" && stanza.attr.get("type") == "set") {
 				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin");
 				if (query != null) {
 					final item = query.getChild("item");
-					Assert.equals("none", item.attr.get("affiliation"));
+					Assert.equals("member", item.attr.get("affiliation"));
 					Assert.equals("other@example.com", item.attr.get("jid"));
 					async.done();
 					return EventHandled;
@@ -450,6 +430,59 @@ class TestChat extends utest.Test {
 			return EventUnhandled;
 		});
 
-		chat.removeRole("channel@example.com/other", borogove.Role.forAffiliation("member"));
+		chat.removeRole(new Member("other", "other", null, true, [new Role("admin", "")], JID.parse("test@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty)), borogove.Role.forAffiliation("admin"));
+	}
+
+	public function testDirectChatMembers(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, "alice@example.com\nbob@example.com");
+
+		chat.members().then(members -> {
+			Assert.equals(3, members.length);
+			Assert.equals("alice@example.com", members[0].id);
+			Assert.equals("bob@example.com", members[1].id);
+			Assert.equals("test@example.com", members[2].id);
+			Assert.isTrue(members[2].isSelf);
+			Assert.equals("alice@example.com (via alice@example.com\nbob@example.com)", members[0].chat.note);
+			async.done();
+		});
+	}
+
+	public function testDirectChatMultiDisplayName() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		client.getDirectChat("bob@example.com").displayName = "Bobby";
+		client.getDirectChat("alice@example.com").displayName = "Alice";
+		final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, "bob@example.com\nalice@example.com");
+
+		Assert.equals("Alice, Bobby", chat.getDisplayName());
+	}
+
+	public function testChannelMembersPassesModeratorFlag(async: Async) {
+		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.members().then(members -> {
+			Assert.isTrue(persistence.forModerator);
+			Assert.equals(1, members.length);
+			Assert.equals("other", members[0].id);
+			async.done();
+		});
+	}
+}
+
+@:access(borogove)
+class ChannelMembersPersistence extends Dummy {
+	public var forModerator = false;
+
+	override public function getMembers(accountId: String, chat: borogove.Chat, forModerator: Bool) {
+		this.forModerator = forModerator;
+		return Promise.resolve([
+			new Member(chat.chatId, "Room", null, false, [], JID.parse(chat.chatId), new Map(), null),
+			new Member("other", "Other", null, false, [], JID.parse("other@example.com"), new Map(), new AvailableChat("other@example.com", "", "", CapsRepo.empty))
+		]);
 	}
 }
diff --git a/test/TestClient.hx b/test/TestClient.hx
index cfa08aa..6330ea4 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -9,8 +9,11 @@ import borogove.ChatMessage;
 import borogove.ChatMessageBuilder;
 import borogove.Client;
 import borogove.JID;
+import borogove.Member;
+import borogove.MemberUpdate;
 import borogove.Message;
 import borogove.ModerationAction;
+import borogove.Role;
 import borogove.Stanza;
 import borogove.Status;
 import borogove.persistence.Dummy;
@@ -513,6 +516,90 @@ class TestClient extends utest.Test {
 
 		client.doSync((_) -> {}, null);
 	}
+
+	public function testMucAffiliationMessageStoresMemberUpdatesAndEmits(async: Async) {
+		final persistence = new MemberUpdateMockPersistence();
+		final client = new Client("test@example.com", persistence);
+		final chat = new borogove.Chat.Channel(client, client.stream, persistence, "room@example.com");
+		client.chats.push(chat);
+		var onPersistedEvent = null;
+		final afterPersistedEvent = new Promise((resolved, rejected) -> { onPersistedEvent = resolved; });
+
+		chat.addMembersUpdatedListener((members) -> {
+			if (persistence.lastUpdates.length > 0) {
+				Assert.equals(1, members.length);
+				Assert.equals("room@example.com/occ-1", members[0].id);
+				Assert.equals("alice@example.com", members[0].chat.chatId);
+				onPersistedEvent(null);
+			}
+		});
+
+		client.stream.onStanza(Stanza.parse('<message from="room@example.com" xmlns="jabber:client">
+			<x xmlns="http://jabber.org/protocol/muc#user">
+				<item affiliation="admin" jid="alice@example.com" nick="Alice">
+					<occupant-id xmlns="urn:xmpp:occupant-id:0" id="occ-1" />
+				</item>
+			</x>
+		</message>'));
+
+		afterPersistedEvent.then(_ -> {
+			Assert.equals(false, persistence.lastIsFullList);
+			Assert.equals("room@example.com", persistence.lastChatId);
+			Assert.equals(1, persistence.lastUpdates.length);
+			if (persistence.lastUpdates.length > 0) {
+				Assert.equals("room@example.com/occ-1", persistence.lastUpdates[0].id);
+			}
+			async.done();
+		});
+	}
+
+	public function testMucAffiliationFullListSetsFlag(async: Async) {
+		final persistence = new MemberUpdateMockPersistence();
+		final client = new Client("test@example.com", persistence);
+		final chat = new borogove.Chat.Channel(client, client.stream, persistence, "room@example.com");
+		client.chats.push(chat);
+
+		client.stream.onStanza(Stanza.parse('<message from="room@example.com" xmlns="jabber:client">
+			<x xmlns="http://jabber.org/protocol/muc#user">
+				<mav xmlns="urn:xmpp:muc:affiliations:1" until="v2" />
+				<item affiliation="admin" jid="alice@example.com" nick="Alice">
+					<occupant-id xmlns="urn:xmpp:occupant-id:0" id="occ-1" />
+				</item>
+			</x>
+		</message>'));
+
+		haxe.Timer.delay(() -> {
+			Assert.equals(true, persistence.lastIsFullList);
+			Assert.equals("v2", chat.mavUntil);
+			async.done();
+		}, 1);
+	}
+
+	public function testConnectClearsMemberPresence(async: Async) {
+		final persistence = new MemberUpdateMockPersistence();
+		final client = new Client("test@example.com", persistence);
+
+		client.stream.on("connect", (data) -> {
+			client.stream.trigger("status/online", { jid: "test@example.com/resource" });
+			return EventHandled;
+		});
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq") {
+				client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "error", id: stanza.attr.get("id") }));
+			}
+			return EventHandled;
+		});
+
+		client.addStatusOnlineListener(() -> {
+			Assert.equals(1, persistence.clearMemberPresenceCalls.length);
+			Assert.equals("test@example.com", persistence.clearMemberPresenceCalls[0].accountId);
+			Assert.isNull(persistence.clearMemberPresenceCalls[0].chatId);
+			async.done();
+		});
+
+		client.start();
+	}
 }
 
 @:access(borogove)
@@ -553,3 +640,34 @@ class MessageMockPersistence extends Dummy {
 		if (message.serverId != null) this.messages.set(message.serverId, message);
 	}
 }
+
+@:access(borogove)
+class MemberUpdateMockPersistence extends Dummy {
+	public var lastUpdates: Array<MemberUpdate> = [];
+	public var lastIsFullList: Null<Bool> = null;
+	public var lastChatId: Null<String> = null;
+	public var clearMemberPresenceCalls: Array<{ accountId: String, chatId: Null<String> }> = [];
+
+	override public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) {
+		lastUpdates = updates;
+		lastIsFullList = isFullList;
+		lastChatId = chat.chatId;
+		return Promise.resolve([
+			new Member(
+				"room@example.com/occ-1",
+				"Alice",
+				null,
+				false,
+				[Role.forAffiliation("admin")],
+				JID.parse("alice@example.com"),
+				new Map(),
+				new borogove.Chat.AvailableChat("alice@example.com", "Alice", "", borogove.CapsRepo.empty)
+			)
+		]);
+	}
+
+	override public function clearMemberPresence(accountId: String, chatId: Null<String>) {
+		clearMemberPresenceCalls.push({ accountId: accountId, chatId: chatId });
+		return Promise.resolve(true);
+	}
+}
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
index 837ee4c..07fa3aa 100644
--- a/test/TestHtml.hx
+++ b/test/TestHtml.hx
@@ -6,7 +6,7 @@ import utest.Async;
 import borogove.Html;
 import borogove.ChatMessageBuilder;
 import borogove.JID;
-import borogove.Participant;
+import borogove.Member;
 
 @:access(borogove)
 class TestHtml extends utest.Test {
@@ -44,7 +44,7 @@ class TestHtml extends utest.Test {
 		msg.sender = msg.from;
 		msg.text = "/me says hello";
 
-		final participant = new Participant("hatter", null, "", false, [], msg.from, null);
+		final participant = new Member("id1", "hatter", null, false, [], msg.from, new Map(), null);
 
 		Assert.equals(
 			"<div class=\"action\"><p>hatter says hello</p></div>",
@@ -60,7 +60,7 @@ class TestHtml extends utest.Test {
 		msg.sender = msg.from;
 		msg.setBody(Html.fromString("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 
-		final participant = new Participant("hatter", null, "", false, [], msg.from, null);
+		final participant = new Member("id1", "hatter", null, false, [], msg.from, new Map(), null);
 
 		Assert.equals(
 			"<div class=\"action\">hatter says <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div></div>",
diff --git a/test/TestParticipant.hx b/test/TestMember.hx
similarity index 88%
rename from test/TestParticipant.hx
rename to test/TestMember.hx
index 60bfb82..afc7da7 100644
--- a/test/TestParticipant.hx
+++ b/test/TestMember.hx
@@ -3,17 +3,17 @@ package test;
 import utest.Assert;
 import utest.Async;
 import borogove.Client;
-import borogove.Participant;
+import borogove.Member;
 import borogove.JID;
 import borogove.Stanza;
 import borogove.persistence.Dummy;
 
 @:access(borogove)
-class TestParticipant extends utest.Test {
+class TestMember extends utest.Test {
 	public function testStatus(async: Async) {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
-		final participant = new Participant("Friend", null, "", false, [], JID.parse("friend@example.com"), null);
+		final participant = new Member("id1", "Friend", null, false, [], JID.parse("friend@example.com"), new Map(), null);
 
 		client.stream.on("sendStanza", (stanza: Stanza) -> {
 			if (stanza.name == "iq" && stanza.attr.get("type") == "get") {
diff --git a/test/TestMemberUpdate.hx b/test/TestMemberUpdate.hx
new file mode 100644
index 0000000..f12bf7f
--- /dev/null
+++ b/test/TestMemberUpdate.hx
@@ -0,0 +1,114 @@
+package test;
+
+import utest.Assert;
+
+import borogove.CapsRepo;
+import borogove.Chat.AvailableChat;
+import borogove.JID;
+import borogove.Member;
+import borogove.MemberUpdate;
+import borogove.Presence;
+import borogove.Role;
+import borogove.Stanza;
+
+@:access(borogove)
+class TestMemberUpdate extends utest.Test {
+	function role(id: String, title = "") {
+		return new Role(id, title == "" ? id : title);
+	}
+
+	function member(?roles: Array<Role>, ?presence: Map<String, Presence>, ?isSelf = false) {
+		return new Member(
+			"room@example.com/occ-1",
+			"Alice",
+			null,
+			isSelf,
+			roles ?? [],
+			JID.parse("alice@example.com"),
+			presence ?? new Map(),
+			new AvailableChat("alice@example.com", "Alice", "", CapsRepo.empty)
+		);
+	}
+
+	public function testApplyToNull() {
+		final update = new MemberUpdate(
+			"room@example.com/occ-1",
+			JID.parse("alice@example.com"),
+			"Alice",
+			false,
+			Role.forAffiliation("admin"),
+			["desk" => Stanza.parse('<presence><show>away</show></presence>')]
+		);
+		final applied = update.applyTo(null);
+
+		Assert.equals("room@example.com/occ-1", applied.id);
+		Assert.equals("Alice", applied.displayName);
+		Assert.equals("alice@example.com", applied.jid.asString());
+		Assert.equals(1, applied.roles.length);
+		Assert.equals("admin", applied.roles[0].id);
+		Assert.notNull(applied.chat);
+		Assert.equals("alice@example.com", applied.chat.chatId);
+		Assert.notNull(applied.presence.get("desk"));
+	}
+
+	public function testApplyToNoneKeepsNonAffiliationRoles() {
+		final update = new MemberUpdate(
+			"room@example.com/occ-1",
+			JID.parse("alice@example.com"),
+			null,
+			false,
+			Role.forAffiliation("none"),
+			new Map()
+		);
+		final applied = update.applyTo(member([
+			role("admin", "Admin"),
+			role("urn:xmpp:hats:test", "Tea Host")
+		]));
+
+		Assert.equals(1, applied.roles.length);
+		Assert.equals("none", applied.roles[0].id);
+	}
+
+	public function testApplyToMergesPresence() {
+		final update = new MemberUpdate(
+			"room@example.com/occ-1",
+			JID.parse("alice@example.com"),
+			null,
+			false,
+			null,
+			["mobile" => Stanza.parse('<presence><priority>5</priority></presence>')]
+		);
+		final applied = update.applyTo(member([], [
+			"desktop" => Stanza.parse('<presence><show>away</show></presence>')
+		]));
+
+		Assert.notNull(applied.presence.get("desktop"));
+		Assert.notNull(applied.presence.get("mobile"));
+	}
+
+	public function testApplyToMismatchedIdThrows() {
+		final update = new MemberUpdate(
+			"room@example.com/occ-2",
+			JID.parse("mallory@example.com"),
+			null,
+			false,
+			null,
+			new Map()
+		);
+
+		Assert.raises(() -> update.applyTo(member()), String);
+	}
+
+	public function testApplyToMismatchedSelfThrows() {
+		final update = new MemberUpdate(
+			"room@example.com/occ-1",
+			JID.parse("alice@example.com"),
+			null,
+			true,
+			null,
+			new Map()
+		);
+
+		Assert.raises(() -> update.applyTo(member([], new Map(), false)), String);
+	}
+}
diff --git a/test/TestSortId.hx b/test/TestSortId.hx
index 39f58f0..91681a6 100644
--- a/test/TestSortId.hx
+++ b/test/TestSortId.hx
@@ -299,9 +299,7 @@ class TestSortId extends utest.Test {
 			return EventHandled;
 		});
 
-		channel.join();
-
-		Promise.resolve(null).then(_ -> {
+		channel.join().then(_ -> {
 			Assert.notNull(queryId);
 			Assert.notNull(iqId);
 
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
index dee0f72..150985a 100644
--- a/test/idb.spec.ts
+++ b/test/idb.spec.ts
@@ -1054,8 +1054,7 @@ test("storeChats and getChats with status", async ({ page }) => {
 		const blob = new Blob([code], { type: "text/javascript" });
 		const borogove = await import(URL.createObjectURL(blob));
 
-		const mediaStore =
-			await borogove.persistence.MediaStoreCache("snikket");
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
 		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
 
 		const chat = Object.create(borogove.DirectChat.prototype);
@@ -1096,12 +1095,567 @@ test("storeStreamManamagement and getStreamManagement", async ({ page }) => {
 		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
 
 		await persistence.storeLogin("alice@example.com", "", "", null); // or updating with SM may not work
-		await persistence.storeStreamManagement("alice@example.com", new Uint8Array([1,2,0,4]).buffer, "ZZ");
+		await persistence.storeStreamManagement(
+			"alice@example.com",
+			new Uint8Array([1, 2, 0, 4]).buffer,
+			"ZZ",
+		);
 		const result = await persistence.getStreamManagement("alice@example.com");
-		return { smIsArrayBuffer: result.sm instanceof ArrayBuffer, smIsEq: result.sm ? indexedDB.cmp(result.sm, new Uint8Array([1,2,0,4]).buffer) : "null", sortId: result.sortId };
+		return {
+			smIsArrayBuffer: result.sm instanceof ArrayBuffer,
+			smIsEq: result.sm
+				? indexedDB.cmp(result.sm, new Uint8Array([1, 2, 0, 4]).buffer)
+				: "null",
+			sortId: result.sortId,
+		};
 	}, code);
 
 	expect(result.smIsEq).toBe(0);
 	expect(result.smIsArrayBuffer).toBe(true);
 	expect(result.sortId).toBe("ZZ");
 });
+
+test("getMembers hydrates persisted member data", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-1@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		const member = {
+			id: "room-members-1@example.com/occ-1",
+			displayName: "Alice",
+			photoUri: "photo:alice",
+			isSelf: false,
+			roles: [{ id: "admin", title: "Admin" }],
+			jid: borogove.JID.parse("alice@example.com"),
+			presence: new Map([
+				[
+					"laptop",
+					borogove.Stanza.parse("<presence><show>away</show></presence>"),
+				],
+			]),
+			chat: { chatId: "alice@example.com" },
+		};
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [member]);
+		const [stored] = await persistence.getMembers(
+			"alice@example.com",
+			chat,
+			false,
+		);
+
+		return {
+			id: stored.id,
+			displayName: stored.displayName,
+			chatId: stored.chat?.chatId,
+			roleIds: stored.roles.map((r) => r.id),
+			presenceKeys: [...stored.presence.keys()],
+			showPresence: stored.showPresence,
+		};
+	}, code);
+
+	expect(result.id).toBe("room-members-1@example.com/occ-1");
+	expect(result.displayName).toBe("Alice");
+	expect(result.chatId).toBe("alice@example.com");
+	expect(result.roleIds).toEqual(["admin"]);
+	expect(result.presenceKeys).toEqual(["laptop"]);
+	expect(result.showPresence).toBe(1);
+});
+
+test("storeMemberUpdates merges existing member data", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-2@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-members-2@example.com/occ-1",
+				displayName: "Alice",
+				photoUri: null,
+				isSelf: false,
+				roles: [
+					{ id: "admin", title: "Admin" },
+					{ id: "urn:xmpp:hats:test", title: "Tea Host" },
+				],
+				jid: borogove.JID.parse("alice@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alice@example.com" },
+			},
+		]);
+
+		const updates = [
+			new borogove.MemberUpdate(
+				"room-members-2@example.com/occ-1",
+				borogove.JID.parse("alice@example.com"),
+				"Alice Cooper",
+				false,
+				null,
+				new Map([["mobile", borogove.Stanza.parse("<presence />")]]),
+			),
+		];
+
+		const updated = await persistence.storeMemberUpdates(
+			"alice@example.com",
+			chat,
+			updates,
+			false,
+		);
+
+		return {
+			updatedRoleIds: updated[0].roles.map((r) => r.id),
+			updatedPresenceKeys: [...updated[0].presence.keys()].sort(),
+			updatedDisplayName: updated[0].displayName,
+		};
+	}, code);
+
+	expect(result.updatedRoleIds).toEqual(["urn:xmpp:hats:test"]);
+	expect(result.updatedPresenceKeys).toEqual(["desk", "mobile"]);
+	expect(result.updatedDisplayName).toBe("Alice Cooper");
+});
+
+test("storeMemberUpdates clears omitted full-list affiliations", async ({
+	page,
+}) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-2b@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-members-2b@example.com/occ-1",
+				displayName: "Alice",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alice@example.com"),
+				presence: new Map(),
+				chat: { chatId: "alice@example.com" },
+			},
+			{
+				id: "room-members-2b@example.com/occ-2",
+				displayName: "Bob",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "owner", title: "Owner" }],
+				jid: borogove.JID.parse("bob@example.com"),
+				presence: new Map(),
+				chat: { chatId: "bob@example.com" },
+			},
+		]);
+
+		await persistence.storeMemberUpdates(
+			"alice@example.com",
+			chat,
+			[
+				new borogove.MemberUpdate(
+					"room-members-2b@example.com/occ-1",
+					borogove.JID.parse("alice@example.com"),
+					"Alice",
+					false,
+					null,
+					new Map(),
+				),
+			],
+			true,
+		);
+		const members = await persistence.getMembers(
+			"alice@example.com",
+			chat,
+			true,
+		);
+
+		return members.find((m) => m.id.endsWith("occ-2")).roles.map((r) => r.id);
+	}, code);
+
+	expect(result).toEqual([]);
+});
+
+test("storeMemberUpdates matches existing member by true JID", async ({
+	page,
+}) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat1 = Object.create(borogove.Channel.prototype);
+		chat1.chatId = "room-members-3@example.com";
+		chat1.getDisplayName = () => "Tea Room";
+		const chat2 = Object.create(borogove.Channel.prototype);
+		chat2.chatId = "room-members-4@example.com";
+		chat2.getDisplayName = () => "Other Room";
+
+		await persistence.storeMembers("alice@example.com", chat1.chatId, [
+			{
+				id: "room-members-3@example.com/occ-1",
+				displayName: "Alice",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alice@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alice@example.com" },
+			},
+			{
+				id: "room-members-4@example.com/occ-1",
+				displayName: "Bob",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("bob@example.com"),
+				presence: new Map([["phone", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "bob@example.com" },
+			},
+		]);
+
+		await persistence.storeMemberUpdates(
+			"alice@example.com",
+			chat1,
+			[
+				new borogove.MemberUpdate(
+					null,
+					borogove.JID.parse("alice@example.com"),
+					"Alice Renamed",
+					false,
+					null,
+					new Map(),
+				),
+			],
+			false,
+		);
+		const [chat1Member] = await persistence.getMemberDetails(
+			"alice@example.com",
+			chat1,
+			["room-members-3@example.com/occ-1"],
+		);
+
+		return chat1Member.displayName;
+	}, code);
+
+	expect(result).toBe("Alice Renamed");
+});
+
+test("clearMemberPresence only clears the targeted chat", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat1 = Object.create(borogove.Channel.prototype);
+		chat1.chatId = "room-members-4a@example.com";
+		chat1.getDisplayName = () => "Tea Room";
+		const chat2 = Object.create(borogove.Channel.prototype);
+		chat2.chatId = "room-members-4b@example.com";
+		chat2.getDisplayName = () => "Other Room";
+
+		await persistence.storeMembers("alice@example.com", chat1.chatId, [
+			{
+				id: "room-members-4a@example.com/occ-1",
+				displayName: "Alice",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alice@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alice@example.com" },
+			},
+		]);
+		await persistence.storeMembers("alice@example.com", chat2.chatId, [
+			{
+				id: "room-members-4b@example.com/occ-1",
+				displayName: "Bob",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("bob@example.com"),
+				presence: new Map([["phone", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "bob@example.com" },
+			},
+		]);
+
+		await persistence.clearMemberPresence("alice@example.com", chat1.chatId);
+		const [chat1Member] = await persistence.getMemberDetails(
+			"alice@example.com",
+			chat1,
+			["room-members-4a@example.com/occ-1"],
+		);
+		const [chat2Member] = await persistence.getMemberDetails(
+			"alice@example.com",
+			chat2,
+			["room-members-4b@example.com/occ-1"],
+		);
+
+		return {
+			chat1PresenceKeys: [...chat1Member.presence.keys()],
+			chat2PresenceKeys: [...chat2Member.presence.keys()],
+		};
+	}, code);
+
+	expect(result.chat1PresenceKeys).toEqual([]);
+	expect(result.chat2PresenceKeys).toEqual(["phone"]);
+});
+
+test("getMembers filters hidden rows for non-moderators", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-5@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-members-5@example.com/owner",
+				displayName: "Zulu",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "owner", title: "Owner" }],
+				jid: borogove.JID.parse("zulu@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "zulu@example.com" },
+			},
+			{
+				id: "room-members-5@example.com/outcast",
+				displayName: "Banned",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "outcast", title: "Banned" }],
+				jid: borogove.JID.parse("banned@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "banned@example.com" },
+			},
+			{
+				id: "room-members-5@example.com/guest-offline",
+				displayName: "Guest",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "none", title: "Guest" }],
+				jid: borogove.JID.parse("guest@example.com"),
+				presence: new Map([
+					["desk", borogove.Stanza.parse('<presence type="unavailable" />')],
+				]),
+				chat: { chatId: "guest@example.com" },
+			},
+			{
+				id: "room-members-5@example.com/guest-offline2",
+				displayName: "Guest2",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "none", title: "Guest" }],
+				jid: borogove.JID.parse("guest2@example.com"),
+				presence: new Map(),
+				chat: { chatId: "guest2@example.com" },
+			},
+			{
+				id: "room-members-5@example.com/admin",
+				displayName: "Alpha",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alpha@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alpha@example.com" },
+			},
+		]);
+
+		const normal = await persistence.getMembers(
+			"alice@example.com",
+			chat,
+			false,
+		);
+
+		return normal.map((m) => m.displayName);
+	}, code);
+
+	expect(result).toEqual(["Zulu", "Alpha"]);
+});
+
+test("getMembers includes moderator-visible rows", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-6@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-members-6@example.com/owner",
+				displayName: "Zulu",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "owner", title: "Owner" }],
+				jid: borogove.JID.parse("zulu@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "zulu@example.com" },
+			},
+			{
+				id: "room-members-6@example.com/outcast",
+				displayName: "Banned",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "outcast", title: "Banned" }],
+				jid: borogove.JID.parse("banned@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "banned@example.com" },
+			},
+			{
+				id: "room-members-6@example.com/guest-offline",
+				displayName: "Guest",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "none", title: "Guest" }],
+				jid: borogove.JID.parse("guest@example.com"),
+				presence: new Map([
+					["desk", borogove.Stanza.parse('<presence type="unavailable" />')],
+				]),
+				chat: { chatId: "guest@example.com" },
+			},
+			{
+				id: "room-members-6@example.com/admin",
+				displayName: "Alpha",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alpha@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alpha@example.com" },
+			},
+		]);
+
+		const moderator = await persistence.getMembers(
+			"alice@example.com",
+			chat,
+			true,
+		);
+		return moderator.map((m) => m.displayName);
+	}, code);
+
+	expect(result).toEqual(["Zulu", "Alpha", "Banned"]);
+});
+
+test("getMemberDetails returns null for incomplete rows", async ({ page }) => {
+	page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: "text/javascript" });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+		const chat = Object.create(borogove.Channel.prototype);
+		chat.chatId = "room-members-7@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-members-7@example.com/admin",
+				displayName: "Alpha",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "admin", title: "Admin" }],
+				jid: borogove.JID.parse("alpha@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "alpha@example.com" },
+			},
+		]);
+
+		const tx = indexedDB.open("snikket");
+		const db = await new Promise((resolve, reject) => {
+			tx.onsuccess = () => resolve(tx.result);
+			tx.onerror = () => reject(tx.error);
+		});
+		const write = db.transaction(["members"], "readwrite");
+		write.objectStore("members").put({
+			account: "alice@example.com",
+			chatId: chat.chatId,
+			id: "room-members-7@example.com/incomplete",
+			displayName: "",
+			photoUri: null,
+			isSelf: 0,
+			chat: "",
+			roles: [],
+			presence: new Map(),
+			jid: "",
+		});
+		await new Promise((resolve, reject) => {
+			write.oncomplete = () => resolve(null);
+			write.onerror = () => reject(write.error);
+		});
+
+		const details = await persistence.getMemberDetails(
+			"alice@example.com",
+			chat,
+			[
+				"room-members-7@example.com/admin",
+				"room-members-7@example.com/incomplete",
+			],
+		);
+		return details.map((m) => (m ? m.displayName : null));
+	}, code);
+
+	expect(result).toEqual(["Alpha", null]);
+});