| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-25 02:54:31 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-25 02:56:00 UTC |
| parent | a7e3c2afa844d972090f6b706f7c59bf70751b24 |
| Makefile | +1 | -1 |
| borogove/CapsRepo.hx | +1 | -0 |
| borogove/Member.hx | +1 | -1 |
| borogove/persistence/IDB.js | +1 | -1 |
| borogove/persistence/Sqlite.hx | +302 | -49 |
| test/TestSqlite.hx | +433 | -0 |
| test/sqlite.spec.ts | +1026 | -3 |
diff --git a/Makefile b/Makefile index 4406508..3fdbd34 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ 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, 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=../$@ + cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_Channel as Channel }" --outfile=../$@ sed -i 's/new URL("sqlite-worker1.mjs", import.meta.url)/window.sqliteWorker1Url/g' $@ playwright/.cache/sqlite-worker1.js: npm diff --git a/borogove/CapsRepo.hx b/borogove/CapsRepo.hx index cf44532..99ecb39 100644 --- a/borogove/CapsRepo.hx +++ b/borogove/CapsRepo.hx @@ -3,6 +3,7 @@ package borogove; import thenshim.Promise; @:nullSafety(StrictThreaded) +@:expose class CapsRepo { public static final empty = new Caps("", [], [], []); private final persistence: Persistence; diff --git a/borogove/Member.hx b/borogove/Member.hx index dff9e17..09ab07a 100644 --- a/borogove/Member.hx +++ b/borogove/Member.hx @@ -58,7 +58,7 @@ class Member { @:allow(borogove) private var presence: Map<String, Presence>; - @:allow(borogove.MemberUpdate) + @:allow(borogove) private final jid: JID; @:allow(borogove) diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 95cc2b5..4101744 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -621,7 +621,7 @@ export default async (dbname, media, tokenize, stemmer) => { } } - return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: chat?.chatId })); + return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: m.chat?.chatId })); }, async clearMemberPresence(account, chatId) { diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 909ad82..1ab99c2 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -11,9 +11,14 @@ import haxe.io.BytesData; import thenshim.Promise; import borogove.Caps; import borogove.Chat; +import borogove.Chat.AvailableChat; import borogove.Message; +import borogove.Member; +import borogove.MemberUpdate; +import borogove.Presence; import borogove.Reaction; import borogove.ReactionUpdate; +import borogove.Role; #if !NO_OMEMO import borogove.OMEMO; using borogove.SignalProtocol; @@ -231,6 +236,26 @@ class Sqlite implements Persistence implements KeyValueStore { "PRAGMA user_version = 10"]); } return Promise.resolve(null); + }).then(_ -> { + if (version < 11) { + return exec(["CREATE TABLE members ( + account_id TEXT NOT NULL, + chat_id TEXT NOT NULL, + member_id TEXT NOT NULL, + display_name TEXT, + photo_uri TEXT, + is_self INTEGER NOT NULL, + chat TEXT, + roles BLOB NOT NULL, + presence BLOB NOT NULL, + jid TEXT, + PRIMARY KEY (account_id, member_id) + ) STRICT", + "CREATE INDEX members_chats ON members (account_id, chat_id, is_self, jid)", + "ALTER TABLE chats DROP COLUMN presence", + "PRAGMA user_version = 11"]); + } + return Promise.resolve(null); }); }); }); @@ -284,20 +309,13 @@ class Sqlite implements Persistence implements KeyValueStore { } storeChatTimer = haxe.Timer.delay(() -> { - final mapPresence = (chat: Chat) -> { - final storePresence: DynamicAccess<String> = {}; - /* TODO for (resource => presence in chat.presence) { - if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = presence.toString(); - }*/ - return storePresence; - }; final q = new StringBuf(); q.add("INSERT OR REPLACE INTO chats VALUES "); var first = true; for (_ in storeChatBuffer) { if (!first) q.add(","); first = false; - q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?,?,?,?,?,jsonb(?))"); + q.add("(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,jsonb(?))"); } db.exec( q.toString(), @@ -308,11 +326,12 @@ class Sqlite implements Persistence implements KeyValueStore { accountId, chat.chatId, chat.isTrusted(), chat.avatarSha1, chat.getDisplayName(), chat.uiState, chat.isBlocked, chat.extensions.toString(), chat.readUpToId, chat.readUpToBy, - channel?.disco?.verRaw().hash, Json.stringify(mapPresence(chat)), + channel?.disco?.verRaw().hash, Type.getClassName(Type.getClass(chat)).split(".").pop(), chat.notificationsFiltered(), chat.notifyMention(), chat.notifyReply(), chat.isBookmarked, JsonPrinter.print({ status: { emoji: chat.status.emoji, text: chat.status.text }, + mavUntil: channel?.mavUntil, threads: { final t: DynamicAccess<String> = {}; for (id => s in chat.threads) t.set(id ?? "", s); @@ -328,34 +347,280 @@ class Sqlite implements Persistence implements KeyValueStore { }, 100); } + private function serializePresenceMap(presence: Map<String, Presence>) { + final storePresence: DynamicAccess<String> = {}; + if (presence == null) return JsonPrinter.print(storePresence); + + for (resource => p in presence) { + storePresence[resource ?? ""] = p.toString(); + } + + return JsonPrinter.print(storePresence); + } + + private function hydratePresenceMap(raw: String): Map<String, Presence> { + final map: Map<String, Presence> = new Map(); + if (raw == null || raw == "") return map; + + final parsed: DynamicAccess<String> = Json.parse(raw); + for (resource => presence in parsed) { + map[resource == "" ? null : resource] = Stanza.parse(presence); + } + + return map; + } + + private function hydrateStoredMember(chat: Null<Chat>, row: Dynamic): Null<Member> { + if (row == null || row.member_id == null || row.member_id == "" || row.display_name == null || row.display_name == "" || row.jid == null || row.jid == "") { + return null; + } + + final rolesData: Array<Dynamic> = row.roles == null ? [] : Json.parse(row.roles); + final roles = rolesData.map(r -> new Role(r.id, r.title)); + final presence = hydratePresenceMap(row.presence); + final chatId: String = row.chat; + final availableChat = chatId == null || chatId == "" ? null : new AvailableChat( + chatId, + row.display_name, + chat == null ? chatId : chatId + " (via " + chat.getDisplayName() + ")", + CapsRepo.empty + ); + + return new Member( + row.member_id, + row.display_name, + row.photo_uri, + row.is_self != 0, + roles, + row.jid == null ? null : JID.parse(row.jid), + presence, + availableChat + ); + } + + private function chatPresenceAndMembersForName(accountId: String, rawChats: Array<Dynamic>): Promise<Map<String, { presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>> }>> { + if (rawChats.length < 1) return Promise.resolve(new Map()); + + final query = " + SELECT chat_id, member_id, display_name, is_self, json(roles) AS roles, json(presence) AS presence + FROM ( + SELECT chat_id, member_id, display_name, is_self, roles, presence, + CASE WHEN is_self OR member_id=chat_id THEN 0 + ELSE ROW_NUMBER() OVER(PARTITION BY chat_id ORDER BY display_name) + END as rn + FROM members + WHERE account_id = ? + AND (is_self OR (display_name IS NOT NULL AND display_name<>'')) + ) sub + WHERE rn <= 23 + ORDER BY chat_id, display_name + "; + + return db.exec(query, [accountId]).then(rows -> { + final result = new Map<String, { presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>> }>(); + + final chatRows = new Map<String, Array<Dynamic>>(); + for (row in rows) { + if (!chatRows.exists(row.chat_id)) chatRows[row.chat_id] = []; + chatRows[row.chat_id].push(row); + } + + for (rawChat in rawChats) { + final cid = rawChat.chat_id; + final members = chatRows[cid]; + if (members == null) { + result[cid] = { presence: new Map(), membersForName: null }; + continue; + } + + var presence: Map<String, Presence> = new Map(); + var membersForName: Null<Array<{id: String, displayName: String}>> = null; + + if (Reflect.field(rawChat, "class") == "DirectChat") { + final selfRow: Dynamic = members.find(r -> r.member_id == cid); + if (selfRow != null) presence = hydratePresenceMap(selfRow.presence); + } else { + membersForName = []; + + for (row in members) { + if (row.is_self != 0) { + presence = hydratePresenceMap(row.presence); + continue; + } + + if (row.member_id == cid) continue; + + final rolesData: Array<Dynamic> = row.roles != null ? Json.parse(row.roles) : []; + if (rolesData.exists(r -> r.id == "none" || r.id == "outcast")) continue; + + membersForName.push({ id: row.member_id, displayName: row.display_name }); + } + + if (membersForName.length > 20) { + membersForName = null; + } else { + membersForName.sort((a, b) -> Reflect.compare(a.displayName, b.displayName)); + } + } + + result[cid] = { presence: presence, membersForName: membersForName }; + } + + return result; + }); + } + @HaxeCBridge.noemit - public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) { - // TODO - return Promise.resolve(false); + public function storeMembers(accountId: String, chatId: String, membersArg: Array<Member>) { + if (membersArg.length < 1) return Promise.resolve(true); + + // To allow being called with member-compatible Dynamics + final members: Array<Dynamic> = cast membersArg; + + final q = new StringBuf(); + q.add("INSERT OR REPLACE INTO members VALUES "); + var first = true; + final params: Array<Dynamic> = []; + final f: Dynamic = false; + for (member in members) { + if (!first) q.add(","); + first = false; + q.add("(?,?,?,?,?,?,?,jsonb(?),jsonb(?),?)"); + params.push(accountId); + params.push(chatId); + params.push(member.id); + params.push(member.displayName); + params.push(member.photoUri); + params.push(member.isSelf); + params.push(member.chat?.chatId); + params.push(JsonPrinter.print(member.roles)); + params.push(serializePresenceMap(member.presence)); + params.push(member.jid?.asString()); + } + + return db.exec(q.toString(), params).then(_ -> true); } @HaxeCBridge.noemit public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) { - // TODO - return Promise.resolve([]); + final updatedIds: Map<String, Bool> = []; + return thenshim.PromiseTools.all(updates.map(update -> { + return (update.id == null ? Promise.resolve(cast null) : db.exec( + "SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id=? AND member_id=? LIMIT 1", + [accountId, update.id] + ).then(rows -> + return rows.hasNext() ? rows.next() : null + )).then(existing -> { + if (existing != null || update.jid == null) return Promise.resolve(existing); + + return db.exec( + "SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id=? AND chat_id=? AND jid=? LIMIT 1", + [accountId, chat.chatId, update.jid.asString()] + ).then(rows -> + return rows.hasNext() ? rows.next() : null + ); + }).then(existing -> { + final member = hydrateStoredMember(chat, existing); + final knownId = update.id ?? existing?.member_id; + if (knownId != null && knownId != "") updatedIds[knownId] = true; + + return update.applyTo(member); + }); + })).then(pseudoMembers -> { + final valid = pseudoMembers.filter(m -> m != null); + return storeMembers(accountId, chat.chatId, cast valid).then(_ -> valid); + }).then(valid -> { + if (!isFullList) return Promise.resolve(valid); + + final ids = [for (id => _ in updatedIds) id]; + return if (ids.length < 1) { + db.exec( + "UPDATE members SET roles=jsonb('[]') WHERE account_id=? AND chat_id=?", + [accountId, chat.chatId] + ).then(_ -> valid); + } else { + final placeholders = ids.map(_ -> "?").join(","); + final params: Array<Dynamic> = [accountId, chat.chatId].concat(ids); + db.exec( + "UPDATE members SET roles=jsonb('[]') WHERE account_id=? AND chat_id=? AND member_id NOT IN (" + placeholders + ")", + params + ).then(_ -> valid); + } + }).then(valid -> { + valid.filter(m -> m?.id != null && m?.displayName != null && m.displayName != "" && m?.jid != null).map(m -> { + return new Member( + m.id, + m.displayName, + m.photoUri, + m.isSelf, + cast m.roles, + m.jid, + m.presence, + m.chat + ); + }); + }); } @HaxeCBridge.noemit public function clearMemberPresence(accountId: String, chatId: Null<String>) { - // TODO - return Promise.resolve(false); + return db.exec( + chatId == null + ? "UPDATE members SET presence=jsonb('{}') WHERE account_id=?" + : "UPDATE members SET presence=jsonb('{}') WHERE account_id=? AND chat_id=?", + chatId == null ? [accountId] : [accountId, chatId] + ).then(_ -> true); } @HaxeCBridge.noemit public function getMembers(accountId: String, chat: Chat, forModerator: Bool) { - // TODO - return Promise.resolve([]); + return db.exec( + "SELECT + member_id, display_name, photo_uri, is_self, chat, + json(roles) AS roles, json(presence) AS presence, jid, + COALESCE( + (SELECT MAX(CASE value->>'$.id' WHEN 'owner' THEN 4 WHEN 'admin' THEN 3 WHEN 'none' THEN 1 WHEN 'outcast' THEN 0 ELSE 2 END) FROM json_each(roles)), + 2 + ) AS role_rank, + CASE WHEN json(presence) NOT LIKE '% type=\\\"unavailable\\\"%' THEN 1 ELSE 0 END AS is_online + FROM members + WHERE + account_id=? AND chat_id=? + AND (? OR NOT EXISTS (SELECT 1 FROM json_each(roles) WHERE value->>'$.id' = 'outcast')) + AND NOT (EXISTS (SELECT 1 FROM json_each(roles) WHERE value->>'$.id' = 'none') AND NOT is_online) + ORDER BY role_rank DESC, is_online DESC, LOWER(display_name) ASC + LIMIT " + (forModerator ? 20000 : 2000), + [accountId, chat.chatId, forModerator] + ).then(rows -> { + final result: Array<Member> = []; + for (row in rows) { + final member = hydrateStoredMember(chat, row); + if (member == null) continue; + + result.push(member); + } + if (result.length > 1000 && !forModerator) return result.filter(m -> m.showPresence != Offline); + return result; + }); } @HaxeCBridge.noemit public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) { - // TODO - return Promise.resolve([]); + if (ids.length == 0) return thenshim.Promise.resolve([]); + + var placeholders = ids.map(_ -> "?").join(", "); + var sql = 'SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id = ? AND member_id IN ($placeholders)'; + + var params = [accountId].concat(ids); + + return db.exec(sql, params).then(rows -> { + var lookup = new Map<String, Member>(); + for (row in rows) { + lookup.set(row.member_id, hydrateStoredMember(chat, row)); + } + + return ids.map(id -> lookup[id]); + }); } @HaxeCBridge.noemit @@ -372,40 +637,28 @@ class Sqlite implements Persistence implements KeyValueStore { @HaxeCBridge.noemit public function getChats(accountId: String): Promise<Array<SerializedChat>> { return db.exec( - "SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(presence) AS presence, json(meta) AS meta, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?", + "SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(meta) AS meta, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?", [accountId] ).then(result -> { - final chats: Array<SerializedChat> = []; - for (row in result) { - final capsJson = row.caps == null ? null : Json.parse(row.caps); - row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver); - final presenceJson: DynamicAccess<Dynamic> = Json.parse(row.presence); - final presenceMap: Map<String, Presence> = []; - for (resource => presence in presenceJson) { - if (Std.isOfType(presence, String)) { - presenceMap[resource] = Stanza.parse(presence); - } else { - presenceMap[resource] = new Presence( - presence.caps == null ? null : new Caps("", [], [], [], Base64.decode(presence.caps).getData()), - presence.mucUser == null ? null : Stanza.parse(presence.mucUser), - presence.avatarHash == null ? null : Hash.fromUri(presence.avatarHash) - ); + final rows = { iterator: () -> result }.array(); + return chatPresenceAndMembersForName(accountId, rows).then(chatData -> { + final chats: Array<SerializedChat> = []; + for (row in rows) { + final capsJson = row.caps == null ? null : Json.parse(row.caps); + row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver); + + final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }>, ?mavUntil: Null<String> } = Json.parse(row.meta); + final threadsMap: StringMapNullableKey<String> = new StringMapNullableKey(); + for (thread => subject in metaJson.threads ?? {}) { + threadsMap.set(thread == "" ? null : thread, subject); } - } - final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }> } = Json.parse(row.meta); - 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 + final derived = chatData[row.chat_id]; + chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, derived.presence ?? new Map(), derived?.membersForName, 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, metaJson.mavUntil, [], Reflect.field(row, "class"))); } - - // FIXME: Empty OMEMO contact device ids hardcoded in next line - // 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; + return chats; + }); }); } diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx index d7be968..00bae9a 100644 --- a/test/TestSqlite.hx +++ b/test/TestSqlite.hx @@ -15,11 +15,16 @@ import borogove.JID; import borogove.ID; import borogove.Message; import borogove.Chat; +import borogove.Chat.AvailableChat; import borogove.Status; import borogove.Reaction; import borogove.ReactionUpdate; import borogove.Html; import borogove.Hash; +import borogove.Member; +import borogove.MemberUpdate; +import borogove.Role; +import borogove.Stanza; using Lambda; using thenshim.PromiseTools; @@ -693,6 +698,7 @@ class TestSqlite extends utest.Test { public function testGetChatUnreadDetails(async: Async) { final account = "alice@example.com"; final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com"); + chat.displayName = "A Chat"; chat.readUpToId = "srv1"; final builder = new ChatMessageBuilder(); @@ -814,6 +820,127 @@ class TestSqlite extends utest.Test { }, 200); } + public function testGetChatsUsesMemberPresenceForDirectChats(async: Async) { + final account = "alice@example.com"; + final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com"); + chat.displayName = "The Mad Hatter"; + chat.trusted = true; + chat.setPresence("desk", Stanza.parse("<presence />"), true); + + persistence.storeChats(account, [chat]); + persistence.storeMembers(account, chat.chatId, [ + new Member( + "hatter@example.com", + "The Mad Hatter", + null, + false, + [], + JID.parse("hatter@example.com"), + ["phone" => Stanza.parse("<presence />")], + null + ) + ]).then(_ -> { + haxe.Timer.delay(() -> { + persistence.getChats(account).then(chats -> { + final stored = chats[0]; + Assert.equals(1, [for (_ in stored.presence.keys()) _].length); + Assert.notNull(stored.presence["phone"]); + Assert.isNull(stored.presence["desk"]); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + }, 200); + return null; + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testGetChatsHydratesMembersForNameAndMavUntil(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-chat-hydrate@example.com"); + chat.displayName = "Tea Room"; + chat.mavUntil = "2024-05-01T12:00:00Z"; + + persistence.storeChats(account, [chat]); + persistence.storeMembers(account, chat.chatId, [ + new Member( + chat.chatId, + "Tea Room", + null, + false, + [], + JID.parse(chat.chatId), + new Map(), + null + ), + new Member( + chat.chatId + "/self", + "Myself", + null, + true, + [new Role("owner", "Owner")], + JID.parse("alice@example.com"), + ["desk" => Stanza.parse("<presence />")], + null + ), + new Member( + chat.chatId + "/zulu", + "Zulu", + null, + false, + [new Role("admin", "Admin")], + JID.parse("zulu@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], [])) + ), + new Member( + chat.chatId + "/alpha", + "Alpha", + null, + false, + [], + JID.parse("alpha@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], [])) + ), + new Member( + chat.chatId + "/hidden", + "Hidden", + null, + false, + [new Role("none", "Guest")], + JID.parse("hidden@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("hidden@example.com", "Hidden", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> { + haxe.Timer.delay(() -> { + persistence.getChats(account).then(chats -> { + final stored = chats[0]; + Assert.notNull(stored.membersForName); + Assert.equals("2024-05-01T12:00:00Z", stored.mavUntil); + Assert.equals(2, stored.membersForName.length); + Assert.equals("Alpha", stored.membersForName[0].displayName); + Assert.equals("Zulu", stored.membersForName[1].displayName); + Assert.equals(1, [for (_ in stored.presence.keys()) _].length); + Assert.notNull(stored.presence["desk"]); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + }, 200); + return null; + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + public function testStoreStreamManamagementAndGetStreamManagement(async: Async) { persistence.storeLogin("alice@example.com", "", "", null).then(_ -> persistence.storeStreamManagement("alice@example.com", Bytes.ofHex("01020004").getData(), "ZZ") @@ -826,4 +953,310 @@ class TestSqlite extends utest.Test { async.done(); }); } + + public function testGetMembersHydratesPersistedMemberData(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-1@example.com"); + chat.displayName = "A Chat"; + chat.trusted = true; + final member = new Member( + "room-members-1@example.com/occ-1", + "Alice", + "photo:alice", + false, + [new Role("admin", "Admin")], + JID.parse("alice@example.com"), + ["laptop" => Stanza.parse('<presence><show>away</show></presence>')], + new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], [])) + ); + + persistence.storeMembers(account, chat.chatId, [member]).then(_ -> + persistence.getMembers(account, chat, false) + ).then(result -> { + Assert.equals(1, result.length); + Assert.equals(member.id, result[0].id); + Assert.equals("Alice", result[0].displayName); + Assert.equals("alice@example.com", result[0].chat.chatId); + Assert.equals("admin", result[0].roles[0].id); + Assert.notNull(result[0].presence.get("laptop")); + Assert.equals(1, cast(result[0].showPresence, Int)); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreMemberUpdatesMergesExistingMemberData(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-2@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member( + "room-members-2@example.com/occ-1", + "Alice", + null, + false, + [new Role("admin", "Admin"), new Role("urn:xmpp:hats:test", "Tea Host")], + JID.parse("alice@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> + persistence.storeMemberUpdates(account, chat, [ + new MemberUpdate( + "room-members-2@example.com/occ-1", + JID.parse("alice@example.com"), + "Alice Cooper", + false, + null, + ["mobile" => Stanza.parse("<presence />")] + ) + ], false) + ).then(result -> { + Assert.equals(1, result.length); + Assert.equals("Alice Cooper", result[0].displayName); + Assert.equals(1, result[0].roles.length); + Assert.equals("urn:xmpp:hats:test", result[0].roles[0].id); + Assert.notNull(result[0].presence.get("desk")); + Assert.notNull(result[0].presence.get("mobile")); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreMemberUpdatesClearsOmittedFullListAffiliations(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-2b@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member( + "room-members-2b@example.com/occ-1", + "Alice", + null, + false, + [new Role("admin", "Admin")], + JID.parse("alice@example.com"), + new Map(), + new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], [])) + ), + new Member( + "room-members-2b@example.com/occ-2", + "Bob", + null, + false, + [new Role("owner", "Owner")], + JID.parse("bob@example.com"), + new Map(), + new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> + persistence.storeMemberUpdates(account, chat, [ + new MemberUpdate( + "room-members-2b@example.com/occ-1", + JID.parse("alice@example.com"), + "Alice", + false, + null, + new Map() + ) + ], true) + ).then(_ -> + persistence.getMembers(account, chat, true) + ).then(result -> { + var bob: Null<Member> = null; + for (member in result) { + if (member.id == "room-members-2b@example.com/occ-2") { + bob = member; + break; + } + } + Assert.notNull(bob); + if (bob != null) Assert.equals(0, bob.roles.length); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreMemberUpdatesMatchesExistingMemberByTrueJid(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-3@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member( + "room-members-3@example.com/occ-1", + "Alice", + null, + false, + [new Role("admin", "Admin")], + JID.parse("alice@example.com"), + new Map(), + new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> + persistence.storeMemberUpdates(account, chat, [ + new MemberUpdate( + null, + JID.parse("alice@example.com"), + "Alice Renamed", + false, + null, + new Map() + ) + ], false) + ).then(_ -> + persistence.getMemberDetails(account, chat, ["room-members-3@example.com/occ-1"]) + ).then(result -> { + Assert.notNull(result[0]); + Assert.equals("Alice Renamed", result[0].displayName); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testClearMemberPresenceOnlyClearsTargetedChat(async: Async) { + final account = "alice@example.com"; + final chat1 = new Channel(cast null, cast null, persistence, "room-members-4a@example.com"); + chat1.displayName = "A Chat"; + final chat2 = new Channel(cast null, cast null, persistence, "room-members-4b@example.com"); + chat2.displayName = "A Chat"; + + persistence.storeMembers(account, chat1.chatId, [ + new Member( + "room-members-4a@example.com/occ-1", + "Alice", + null, + false, + [new Role("admin", "Admin")], + JID.parse("alice@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> + persistence.storeMembers(account, chat2.chatId, [ + new Member( + "room-members-4b@example.com/occ-1", + "Bob", + null, + false, + [new Role("admin", "Admin")], + JID.parse("bob@example.com"), + ["phone" => Stanza.parse("<presence />")], + new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], [])) + ) + ]) + ).then(_ -> + persistence.clearMemberPresence(account, chat1.chatId) + ).then(_ -> + persistence.getMemberDetails(account, chat1, ["room-members-4a@example.com/occ-1"]).then(result1 -> + persistence.getMemberDetails(account, chat2, ["room-members-4b@example.com/occ-1"]).then(result2 -> + Promise.resolve([result1[0], result2[0]]) + ) + ) + ).then(result -> { + Assert.notNull(result[0]); + Assert.notNull(result[1]); + Assert.equals(0, result[0].presence.keys().hasNext() ? 1 : 0); + Assert.notNull(result[1].presence.get("phone")); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testGetMembersFiltersHiddenRowsForNonModerators(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-5@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member("room-members-5@example.com/owner", "Zulu", null, false, [new Role("owner", "Owner")], JID.parse("zulu@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], []))), + new Member("room-members-5@example.com/outcast", "Banned", null, false, [new Role("outcast", "Banned")], JID.parse("banned@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("banned@example.com", "Banned", "", new borogove.Caps("", [], [], []))), + new Member("room-members-5@example.com/guest-offline", "Guest", null, false, [new Role("none", "Guest")], JID.parse("guest@example.com"), ["desk" => Stanza.parse('<presence type="unavailable" />')], new AvailableChat("guest@example.com", "Guest", "", new borogove.Caps("", [], [], []))), + new Member("room-members-5@example.com/admin", "Alpha", null, false, [new Role("admin", "Admin")], JID.parse("alpha@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], []))) + ]).then(_ -> + persistence.getMembers(account, chat, false) + ).then(result -> { + Assert.same(["Zulu", "Alpha"], result.map(m -> m.displayName)); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testGetMembersIncludesModeratorVisibleRows(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-6@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member("room-members-6@example.com/owner", "Zulu", null, false, [new Role("owner", "Owner")], JID.parse("zulu@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], []))), + new Member("room-members-6@example.com/outcast", "Banned", null, false, [new Role("outcast", "Banned")], JID.parse("banned@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("banned@example.com", "Banned", "", new borogove.Caps("", [], [], []))), + new Member("room-members-6@example.com/guest-offline", "Guest", null, false, [new Role("none", "Guest")], JID.parse("guest@example.com"), ["desk" => Stanza.parse('<presence type="unavailable" />')], new AvailableChat("guest@example.com", "Guest", "", new borogove.Caps("", [], [], []))), + new Member("room-members-6@example.com/admin", "Alpha", null, false, [new Role("admin", "Admin")], JID.parse("alpha@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], []))) + ]).then(_ -> + persistence.getMembers(account, chat, true) + ).then(result -> { + Assert.same(["Zulu", "Alpha", "Banned"], result.map(m -> m.displayName)); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testGetMemberDetailsReturnsNullForIncompleteRows(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-members-7@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member( + "room-members-7@example.com/admin", + "Alpha", + null, + false, + [new Role("admin", "Admin")], + JID.parse("alpha@example.com"), + ["desk" => Stanza.parse("<presence />")], + new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], [])) + ) + ]).then(_ -> { + return untyped persistence.db.exec('INSERT INTO members(account_id, chat_id, member_id, display_name, photo_uri, is_self, chat, roles, presence, jid) VALUES(?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)', [ + account, + chat.chatId, + "room-members-7@example.com/incomplete", + "", + null, + 0, + "{}", + "[]", + "{}", + "" + ]); + }).then(_ -> + persistence.getMemberDetails(account, chat, [ + "room-members-7@example.com/admin", + "room-members-7@example.com/incomplete" + ]) + ).then(result -> { + Assert.equals("Alpha", result[0]?.displayName); + Assert.isNull(result[1]); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } } diff --git a/test/sqlite.spec.ts b/test/sqlite.spec.ts index 4213d63..bf73efb 100644 --- a/test/sqlite.spec.ts +++ b/test/sqlite.spec.ts @@ -1560,6 +1560,210 @@ test.describe("not webkit", () => { expect(result.statusText).toBe("Time for tea!"); }); + test("getChats uses member presence for direct chats", 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 = new borogove.DirectChat( + null, + null, + persistence, + "hatter@example.com", + ); + chat.displayName = "The Mad Hatter"; + chat.trusted = true; + + try { + await persistence.storeChats("alice@example.com", [chat]); + await new Promise((resolve) => setTimeout(resolve, 200)); + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "hatter@example.com", + displayName: "The Mad Hatter", + photoUri: null, + isSelf: false, + roles: [], + jid: borogove.JID.parse("hatter@example.com"), + presence: new Map([ + ["phone", borogove.Stanza.parse("<presence />")], + ]), + chat: null, + }, + ]); + const [stored] = await persistence.getChats("alice@example.com"); + return [...stored.presence.keys()].sort(); + } catch (e) { + console.error(e, e.result); + throw e.result ? JSON.stringify(e.result) : e.message; + } + }, + [code, sqlite, worker1], + ); + + expect(result).toEqual(["phone"]); + }); + + test("getChats hydrates membersForName and mavUntil from members", 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 = new sqlite.Channel( + null, + null, + persistence, + "room-chat-hydrate@example.com", + ); + chat.displayName = "Tea Room"; + chat.trusted = true; + chat.mavUntil = "2024-05-01T12:00:00Z"; + + await persistence.storeChats("alice@example.com", [chat]); + await new Promise((resolve) => setTimeout(resolve, 200)); + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: chat.chatId, + displayName: "Tea Room", + photoUri: null, + isSelf: false, + roles: [], + jid: borogove.JID.parse(chat.chatId), + presence: new Map(), + chat: null, + }, + { + id: `${chat.chatId}/self`, + displayName: "Myself", + photoUri: null, + isSelf: true, + roles: [{ id: "owner", title: "Owner" }], + jid: borogove.JID.parse("alice@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse("<presence />")], + ]), + chat: null, + }, + { + id: `${chat.chatId}/zulu`, + displayName: "Zulu", + photoUri: null, + isSelf: false, + roles: [{ id: "admin", title: "Admin" }], + jid: borogove.JID.parse("zulu@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse("<presence />")], + ]), + chat: { chatId: "zulu@example.com" }, + }, + { + id: `${chat.chatId}/alpha`, + displayName: "Alpha", + photoUri: null, + isSelf: false, + roles: [], + jid: borogove.JID.parse("alpha@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse("<presence />")], + ]), + chat: { chatId: "alpha@example.com" }, + }, + { + id: `${chat.chatId}/hidden`, + displayName: "Hidden", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Guest" }], + jid: borogove.JID.parse("hidden@example.com"), + presence: new Map([ + ["desk", borogove.Stanza.parse("<presence />")], + ]), + chat: { chatId: "hidden@example.com" }, + }, + ]); + const [stored] = await persistence.getChats("alice@example.com"); + return { + mavUntil: stored.mavUntil, + membersForName: stored.membersForName.map((m) => m.displayName), + presenceKeys: [...stored.presence.keys()].sort(), + }; + }, + [code, sqlite, worker1], + ); + + expect(result.mavUntil).toBe("2024-05-01T12:00:00Z"); + expect(result.membersForName).toEqual(["Alpha", "Zulu"]); + expect(result.presenceKeys).toEqual(["desk"]); + }); + test("storeStreamManamagement and getStreamManagement", async ({ page }) => { page.route("https://localhost/", (route) => route.fulfill({ @@ -1600,9 +1804,20 @@ test.describe("not webkit", () => { try { 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"); - 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 }; + 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, + }; } catch (e) { console.error(e, e.result); throw e.result ? JSON.stringify(e.result) : e.message; @@ -1615,4 +1830,812 @@ test.describe("not webkit", () => { 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>", + 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-1@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + 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" }, + }, + ]); + + const members = await persistence.getMembers( + "alice@example.com", + chat, + false, + ); + return { + id: members[0]?.id, + displayName: members[0]?.displayName, + chatId: members[0]?.chat?.chatId, + roleIds: members[0]?.roles?.map((r) => r.id), + presenceKeys: members[0] ? [...members[0].presence.keys()] : [], + showPresence: members[0]?.showPresence, + }; + }, + [code, sqlite, worker1], + ); + + 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>", + 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-2@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + 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 updated = await persistence.storeMemberUpdates( + "alice@example.com", + chat, + [ + 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 />")]]), + ), + ], + false, + ); + + return { + displayName: updated[0]?.displayName, + roleIds: updated[0]?.roles?.map((r) => r.id), + presenceKeys: updated[0] + ? [...updated[0].presence.keys()].sort() + : [], + }; + }, + [code, sqlite, worker1], + ); + + expect(result.displayName).toBe("Alice Cooper"); + expect(result.roleIds).toEqual(["urn:xmpp:hats:test"]); + expect(result.presenceKeys).toEqual(["desk", "mobile"]); + }); + + test("storeMemberUpdates clears omitted full-list affiliations", 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-2b@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + 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, sqlite, worker1], + ); + + expect(result).toEqual([]); + }); + + test("storeMemberUpdates matches existing member by true JID", 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-3@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + await persistence.storeMembers("alice@example.com", chat.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(), + chat: { chatId: "alice@example.com" }, + }, + ]); + + await persistence.storeMemberUpdates( + "alice@example.com", + chat, + [ + new borogove.MemberUpdate( + null, + borogove.JID.parse("alice@example.com"), + "Alice Renamed", + false, + null, + new Map(), + ), + ], + false, + ); + const [member] = await persistence.getMemberDetails( + "alice@example.com", + chat, + ["room-members-3@example.com/occ-1"], + ); + return member?.displayName; + }, + [code, sqlite, worker1], + ); + + expect(result).toBe("Alice Renamed"); + }); + + test("clearMemberPresence only clears the targeted chat", 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 chat1 = new borogove.Channel( + null, + null, + persistence, + "room-members-4a@example.com", + ); + chat1.displayName = "A Chat"; + chat1.trusted = true; + const chat2 = new borogove.Channel( + null, + null, + persistence, + "room-members-4b@example.com", + ); + chat2.displayName = "A Chat"; + chat2.trusted = true; + + 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 [member1] = await persistence.getMemberDetails( + "alice@example.com", + chat1, + ["room-members-4a@example.com/occ-1"], + ); + const [member2] = await persistence.getMemberDetails( + "alice@example.com", + chat2, + ["room-members-4b@example.com/occ-1"], + ); + return { + chat1PresenceKeys: member1 ? [...member1.presence.keys()] : [], + chat2PresenceKeys: member2 ? [...member2.presence.keys()] : [], + }; + }, + [code, sqlite, worker1], + ); + + 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>", + 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-5@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + 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/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 members = await persistence.getMembers( + "alice@example.com", + chat, + false, + ); + return members.map((m) => m.displayName); + }, + [code, sqlite, worker1], + ); + + expect(result).toEqual(["Zulu", "Alpha"]); + }); + + test("getMembers includes moderator-visible rows", 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-6@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + 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 members = await persistence.getMembers( + "alice@example.com", + chat, + true, + ); + return members.map((m) => m.displayName); + }, + [code, sqlite, worker1], + ); + + 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>", + 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 = new borogove.Channel( + null, + null, + persistence, + "room-members-7@example.com", + ); + chat.displayName = "A Chat"; + chat.trusted = true; + + 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" }, + }, + ]); + + await persistence.db.exec( + "INSERT INTO members(account_id, chat_id, member_id, display_name, photo_uri, is_self, chat, roles, presence, jid) VALUES(?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)", + [ + "alice@example.com", + chat.chatId, + "room-members-7@example.com/incomplete", + "", + null, + 0, + "{}", + "[]", + "{}", + "", + ], + ); + 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, sqlite, worker1], + ); + + expect(result).toEqual(["Alpha", null]); + }); });