git » sdk » commit ef28296

Initial support for voice requests

author Stephen Paul Weber
2026-06-23 16:08:20 UTC
committer Stephen Paul Weber
2026-06-23 16:08:20 UTC
parent 588d49f1c1247d6780a79ce746ed37e545bae05f

Initial support for voice requests

borogove/Chat.hx +31 -2
borogove/Client.hx +10 -0
borogove/Message.hx +15 -0
borogove/MucUser.hx +5 -0
borogove/Persistence.hx +21 -0
borogove/persistence/Dummy.hx +10 -0
borogove/persistence/IDB.js +28 -1
borogove/persistence/Sqlite.hx +20 -0
test/TestChat.hx +39 -0
test/TestClient.hx +47 -0
test/TestSqlite.hx +32 -0
test/idb.spec.ts +58 -0
test/sqlite.spec.ts +86 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 459fd8c..4424a51 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1753,9 +1753,9 @@ class Channel extends Chat {
 				});
 			}
 		}
-		final mucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user");
+		final mucUser: MucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user");
 		if (mucUser != null) {
-			final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1");
+			final mav = mucUser.mav;
 			if (mav?.attr?.get("since") != null && mav?.attr?.get("since") != mavUntil) {
 				trace("MAV update with unknown previous version", mavUntil, presence);
 			}
@@ -1763,6 +1763,10 @@ class Channel extends Chat {
 				mavUntil = mav?.attr?.get("until");
 				persistence.storeChats(client.accountId(), [this]);
 			}
+			if (mucUser.role != "visitor" && mucUser.jid != null) {
+				// If they're not a visitor they can't be requesting voice
+				persistence.storeVoiceRequest(client.accountId(), this, mucUser.jid.asString(), false);
+			}
 		}
 		if (member.isSelf) {
 			if (presence.type == "unavailable") {
@@ -2028,6 +2032,31 @@ class Channel extends Chat {
 		return persistence.getMemberDetails(client.accountId(), this, memberIds);
 	}
 
+	/**
+		List of current voice requests we are aware of
+	**/
+	public function voiceRequests() {
+		return persistence.listVoiceRequests(client.accountId(), this);
+	}
+
+	/**
+		Respond to a particular voice request
+	**/
+	public function voiceRequestRespond(member: Member, canSend: Bool) {
+		if (member.chat == null) return;
+
+		// For canSend=true we could use normal set role to participant as well
+		final outboxItem = outbox.newItem();
+		outboxItem.handle(() -> client.sendStanza(
+			new Stanza("message", { to: chatId })
+			.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+			.tag("field", { "var": "FORM_TYPE" }).textTag("value", "http://jabber.org/protocol/muc#request").up()
+			.tag("field", { "var": "muc#role" }).textTag("value", "participant").up()
+			.tag("field", { "var": "muc#jid" }).textTag("value", member.chat.chatId).up()
+			.tag("field", { "var": "muc#request_allow" }).textTag("value", canSend ? "1" : "0").up()
+		));
+	}
+
 	private function buildMember(resource: String, presence: Presence): Member {
 		final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110");
 		final jid = JID.parse(chatId).withResource(resource);
diff --git a/borogove/Client.hx b/borogove/Client.hx
index c0f7ae1..85ae47b 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -535,6 +535,16 @@ class Client extends EventEmitter {
 					persistence.storeChats(accountId(), [chat]);
 					this.trigger("chats/update", [chat]);
 				}
+			case MucVoiceRequest(jid):
+				final chat = getChat(message.chatId);
+				final channel = chat == null ? null : Util.downcast(chat, Channel);
+				if (channel == null) {
+					trace("Ignoring voice request from unknown channel: " + stanza.toString());
+				} else {
+					persistence.storeVoiceRequest(accountId(), channel, jid.asBare().asString(), true).then(_ -> {
+						this.trigger("chats/update", [channel]);
+					});
+				}
 			default:
 				// ignore
 				trace("Ignoring non-chat message: " + stanza.toString());
diff --git a/borogove/Message.hx b/borogove/Message.hx
index 1ec0a35..229a8a1 100644
--- a/borogove/Message.hx
+++ b/borogove/Message.hx
@@ -40,6 +40,7 @@ enum MessageStanza {
 	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
 	MucInviteStanza(serverId: Null<String>, serverIdBy: Null<String>, reason: Null<String>, password: Null<String>);
+	MucVoiceRequest(jid: JID);
 	UnknownMessageStanza(stanza: Stanza);
 }
 
@@ -94,6 +95,20 @@ class Message {
 		msg.to = to == null ? localJid : JID.parse(to);
 		msg.encryption = encryptionInfo;
 
+		// Voice request is just a message form, but we want to handle it special
+		final form: Null<DataForm> = stanza.getChild("x", "jabber:x:data");
+		if (
+			form != null &&
+			form.type == "form" &&
+			"http://jabber.org/protocol/muc#request" == form.field("FORM_TYPE")?.value?.join(" ") &&
+			"participant" == form.field("muc#role")?.value?.join(" ")
+		) {
+				final jid = form.field("muc#jid")?.value;
+				if (jid != null && jid.length == 1) {
+					return new Message(from, from, null, MucVoiceRequest(JID.parse(jid[0])), encryptionInfo);
+				}
+		}
+
 		if (msg.from != null && msg.from.equals(localJidBare)) {
 			var carbon = stanza.getChild("received", "urn:xmpp:carbons:2");
 			if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2");
diff --git a/borogove/MucUser.hx b/borogove/MucUser.hx
index 1c8ced7..9902994 100644
--- a/borogove/MucUser.hx
+++ b/borogove/MucUser.hx
@@ -9,6 +9,7 @@ abstract MucUser(Stanza) from Stanza to Stanza {
 	public var role(get, never): String;
 	public var affiliation(get, never): String;
 	public var jid(get, never): Null<JID>;
+	public var mav(get, never): Null<Stanza>;
 
 	inline private function get_statusCodes() {
 		return this.allTags("status").map(el -> el.attr.get("code"));
@@ -32,4 +33,8 @@ abstract MucUser(Stanza) from Stanza to Stanza {
 	inline private function item() {
 		return this.getChild("item");
 	}
+
+	inline private function get_mav() {
+		return this.getChild("mav", "urn:xmpp:muc:affiliations:1");
+	}
 }
diff --git a/borogove/Persistence.hx b/borogove/Persistence.hx
index 6695130..ea455a7 100644
--- a/borogove/Persistence.hx
+++ b/borogove/Persistence.hx
@@ -102,6 +102,27 @@ interface Persistence {
 	@HaxeCBridge.noemit
 	public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>): Promise<Array<Null<Member>>>;
 
+	/**
+		Store a voice request by Jabber ID
+
+		@param accountId the account that owns the member records
+		@param chat the Channel the request is for
+		@param jid the Jabber ID that is requesting / no longer requesting voice
+		@returns Promise resolving to true
+	**/
+	@HaxeCBridge.noemit
+	public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool): Promise<Bool>;
+
+	/**
+		List known voice requests
+
+		@param accountId the account that owns the member records
+		@param chat the Channels to look in
+		@returns Promise resolving to the list of Members requesting voice
+	**/
+	@HaxeCBridge.noemit
+	public function listVoiceRequests(accountId: String, chat: Chat): Promise<Array<Member>>;
+
 	/**
 		Load unread counters and most recent unread message per Chat
 
diff --git a/borogove/persistence/Dummy.hx b/borogove/persistence/Dummy.hx
index e23f041..9b73588 100644
--- a/borogove/persistence/Dummy.hx
+++ b/borogove/persistence/Dummy.hx
@@ -67,6 +67,16 @@ class Dummy implements Persistence {
 		return Promise.resolve([]);
 	}
 
+	@HaxeCBridge.noemit
+	public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool) {
+		return Promise.resolve(false);
+	}
+
+	@HaxeCBridge.noemit
+	public function listVoiceRequests(accountId: String, chat: Chat) {
+		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 802ff9b..6ef27f7 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -701,6 +701,29 @@ export default async (dbname, media, tokenize, stemmer) => {
 			}));
 		},
 
+		async storeVoiceRequest(account, chat, jid, requesting) {
+			await this.set(`voiceRequest:${account}\n${chat.chatId}\n${jid}`, requesting ? true : undefined);
+			return true;
+		},
+
+		async listVoiceRequests(account, chat) {
+			const tx = db.transaction(["keyvaluepairs", "members"], "readonly");
+			const kvStore = tx.objectStore("keyvaluepairs");
+			const keys = await promisifyRequest(kvStore.getAllKeys(IDBKeyRange.bound(`voiceRequest:${account}\n${chat.chatId}\n`, `voiceRequest:${account}\n${chat.chatId}\n\uffff`)));
+			const jids = keys.map(k => k.split(/\n/)[2]);
+
+			const result = [];
+			const store = tx.objectStore("members");
+			for (const jid of jids) {
+				const raw = await promisifyRequest(store.index("chatsWithTrueJid").get([account, chat.chatId, 0, jid]));
+				if (!raw?.id || !raw?.displayName || !raw?.jid) continue;
+
+				result.push(hydrateMember(chat, raw));
+			}
+
+			return result;
+		},
+
 		getChatUnreadDetails: async function(account, chat) {
 			const tx = db.transaction(["messages"], "readonly");
 			const store = tx.objectStore("messages");
@@ -1491,7 +1514,11 @@ tx.onerror = console.error;
 		set(k, v) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			return promisifyRequest(store.put(v, k));
+			if (typeof(v) === "undefined") {
+				return promisifyRequest(store.delete(k));
+			} else {
+				return promisifyRequest(store.put(v, k));
+			}
 		}
 	};
 
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 1d35a3d..f87c6e7 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -629,6 +629,26 @@ class Sqlite implements Persistence implements KeyValueStore {
 		});
 	}
 
+	@HaxeCBridge.noemit
+	public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool) {
+		return set("voiceRequest:" + accountId + "\n" + chat.chatId + "\n" + jid, requesting ? "1" : null).then(_ -> true);
+	}
+
+	@HaxeCBridge.noemit
+	public function listVoiceRequests(accountId: String, chat: Chat) {
+		return db.exec(
+			"SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members INNER JOIN keyvaluepairs ON keyvaluepairs.k='voiceRequest:' || account_id || char(10) || chat_id || char(10) || jid WHERE account_id=? AND chat_id=?",
+			[accountId, chat.chatId]
+		).then(rows -> {
+			final result: Array<Member> = [];
+			for (row in rows) {
+				final member = hydrateStoredMember(chat, row);
+				if (member != null) result.push(member);
+			}
+			return result;
+		});
+	}
+
 	@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 ?";
diff --git a/test/TestChat.hx b/test/TestChat.hx
index 590adcf..acbeede 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -694,6 +694,45 @@ class TestChat extends utest.Test {
 		chat.outbox.start();
 		chat.requestToSend();
 	}
+
+	public function testVoiceRequestRespond(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new borogove.Chat.Channel(client, client.stream, persistence, "channel@example.com");
+		final member = new Member("some_id", "some_name", null, false, [], JID.parse("someone@example.com"), new Map(), new AvailableChat("someone_chat@example.com", "", "", CapsRepo.empty));
+
+		var count = 0;
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "message" && stanza.attr.get("to") == "channel@example.com" && stanza.attr.get("type") == null) {
+				final x = stanza.getChild("x", "jabber:x:data");
+				if (x != null && x.attr.get("type") == "submit") {
+					final fields = x.allTags("field");
+					Assert.equals(4, fields.length);
+					Assert.equals("FORM_TYPE", fields[0].attr.get("var"));
+					Assert.equals("http://jabber.org/protocol/muc#request", fields[0].getChild("value").getText());
+					Assert.equals("muc#role", fields[1].attr.get("var"));
+					Assert.equals("participant", fields[1].getChild("value").getText());
+					Assert.equals("muc#jid", fields[2].attr.get("var"));
+					Assert.equals("someone_chat@example.com", fields[2].getChild("value").getText());
+					Assert.equals("muc#request_allow", fields[3].attr.get("var"));
+
+					if (count == 0) {
+						Assert.equals("1", fields[3].getChild("value").getText());
+					} else {
+						Assert.equals("0", fields[3].getChild("value").getText());
+						async.done();
+					}
+					count++;
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		chat.outbox.start();
+		chat.voiceRequestRespond(member, true);
+		chat.voiceRequestRespond(member, false);
+	}
 }
 
 @:access(borogove)
diff --git a/test/TestClient.hx b/test/TestClient.hx
index 6330ea4..2f006a3 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -600,6 +600,43 @@ class TestClient extends utest.Test {
 
 		client.start();
 	}
+
+	public function testMucVoiceRequest(async: Async) {
+		final persistence = new VoiceRequestMockPersistence();
+		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; });
+
+		client.on("chats/update", (chats: Array<Chat>) -> {
+			final c = chats.find(x -> x.chatId == "room@example.com");
+			if (c != null && persistence.lastVoiceRequests.length > 0) {
+				onPersistedEvent(null);
+			}
+			return EventHandled;
+		});
+
+		final stanza = Stanza.parse('<message from="room@example.com" xmlns="jabber:client">
+			<x xmlns="jabber:x:data" type="form">
+				<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#request</value></field>
+				<field var="muc#role"><value>participant</value></field>
+				<field var="muc#jid"><value>requester@example.com</value></field>
+			</x>
+		</message>');
+
+		client.stream.onStanza(stanza);
+
+		afterPersistedEvent.then(_ -> {
+			Assert.equals(1, persistence.lastVoiceRequests.length);
+			Assert.equals("test@example.com", persistence.lastVoiceRequests[0].accountId);
+			Assert.equals("room@example.com", persistence.lastVoiceRequests[0].chat.chatId);
+			Assert.equals("requester@example.com", persistence.lastVoiceRequests[0].jid);
+			Assert.equals(true, persistence.lastVoiceRequests[0].requesting);
+			async.done();
+		});
+	}
 }
 
 @:access(borogove)
@@ -671,3 +708,13 @@ class MemberUpdateMockPersistence extends Dummy {
 		return Promise.resolve(true);
 	}
 }
+
+@:access(borogove)
+class VoiceRequestMockPersistence extends Dummy {
+	public var lastVoiceRequests: Array<{ accountId: String, chat: Chat, jid: String, requesting: Bool }> = [];
+
+	override public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool): Promise<Bool> {
+		lastVoiceRequests.push({ accountId: accountId, chat: chat, jid: jid, requesting: requesting });
+		return Promise.resolve(true);
+	}
+}
diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx
index 7432c54..8a5f219 100644
--- a/test/TestSqlite.hx
+++ b/test/TestSqlite.hx
@@ -1288,4 +1288,36 @@ class TestSqlite extends utest.Test {
 			async.done();
 		});
 	}
+
+	@:timeout(3000)
+	public function testVoiceRequests(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-voice-requests@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member("room-voice-requests@example.com/bob", "Bob", null, false, [new Role("none", "Participant")], JID.parse("bob@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], []))),
+			new Member("room-voice-requests@example.com/charlie", "Charlie", null, false, [new Role("none", "Participant")], JID.parse("charlie@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("charlie@example.com", "Charlie", "", new borogove.Caps("", [], [], [])))
+		]).then(_ ->
+			persistence.storeVoiceRequest(account, chat, "bob@example.com", true)
+		).then(_ ->
+			persistence.storeVoiceRequest(account, chat, "charlie@example.com", true)
+		).then(_ ->
+			persistence.listVoiceRequests(account, chat)
+		).then(reqs1 -> {
+			final reqsNames = reqs1.map(m -> m.displayName);
+			reqsNames.sort((a, b) -> Reflect.compare(a, b));
+			Assert.same(["Bob", "Charlie"], reqsNames);
+			return persistence.storeVoiceRequest(account, chat, "bob@example.com", false);
+		}).then(_ ->
+			persistence.listVoiceRequests(account, chat)
+		).then(reqs2 -> {
+			final reqsNames2 = reqs2.map(m -> m.displayName);
+			Assert.same(["Charlie"], reqsNames2);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
 }
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
index 150985a..ed33b7b 100644
--- a/test/idb.spec.ts
+++ b/test/idb.spec.ts
@@ -1659,3 +1659,61 @@ test("getMemberDetails returns null for incomplete rows", async ({ page }) => {
 
 	expect(result).toEqual(["Alpha", null]);
 });
+
+test("storeVoiceRequest and listVoiceRequests", 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-voice-requests@example.com";
+		chat.getDisplayName = () => "Tea Room";
+
+		await persistence.storeMembers("alice@example.com", chat.chatId, [
+			{
+				id: "room-voice-requests@example.com/occ-1",
+				displayName: "Bob",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "none", title: "Participant" }],
+				jid: borogove.JID.parse("bob@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "bob@example.com" },
+			},
+			{
+				id: "room-voice-requests@example.com/occ-2",
+				displayName: "Charlie",
+				photoUri: null,
+				isSelf: false,
+				roles: [{ id: "none", title: "Participant" }],
+				jid: borogove.JID.parse("charlie@example.com"),
+				presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+				chat: { chatId: "charlie@example.com" },
+			}
+		]);
+
+		await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", true);
+		await persistence.storeVoiceRequest("alice@example.com", chat, "charlie@example.com", true);
+
+		const requests1 = await persistence.listVoiceRequests("alice@example.com", chat);
+
+		await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", false);
+
+		const requests2 = await persistence.listVoiceRequests("alice@example.com", chat);
+
+		return {
+			requests1: requests1.map((m) => m.displayName).sort(),
+			requests2: requests2.map((m) => m.displayName).sort(),
+		};
+	}, code);
+
+	expect(result.requests1).toEqual(["Bob", "Charlie"]);
+	expect(result.requests2).toEqual(["Charlie"]);
+});
diff --git a/test/sqlite.spec.ts b/test/sqlite.spec.ts
index 64d48aa..6c10e0a 100644
--- a/test/sqlite.spec.ts
+++ b/test/sqlite.spec.ts
@@ -2640,4 +2640,90 @@ test.describe("not webkit", () => {
 
 		expect(result).toEqual(["Alpha", null]);
 	});
+
+	test("storeVoiceRequest and listVoiceRequests", async ({ page }) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+
+				const chat = Object.create(borogove.Channel.prototype);
+				chat.chatId = "room-voice-requests@example.com";
+				chat.getDisplayName = () => "Tea Room";
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-voice-requests@example.com/occ-1",
+						displayName: "Bob",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "none", title: "Participant" }],
+						jid: borogove.JID.parse("bob@example.com"),
+						presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+						chat: { chatId: "bob@example.com" },
+					},
+					{
+						id: "room-voice-requests@example.com/occ-2",
+						displayName: "Charlie",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "none", title: "Participant" }],
+						jid: borogove.JID.parse("charlie@example.com"),
+						presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]),
+						chat: { chatId: "charlie@example.com" },
+					}
+				]);
+
+				await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", true);
+				await persistence.storeVoiceRequest("alice@example.com", chat, "charlie@example.com", true);
+
+				const requests1 = await persistence.listVoiceRequests("alice@example.com", chat);
+
+				await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", false);
+
+				const requests2 = await persistence.listVoiceRequests("alice@example.com", chat);
+
+				return {
+					requests1: requests1.map((m) => m.displayName).sort(),
+					requests2: requests2.map((m) => m.displayName).sort(),
+				};
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result.requests1).toEqual(["Bob", "Charlie"]);
+		expect(result.requests2).toEqual(["Charlie"]);
+	});
 });