From 7e24f5eba31474aea1e3a58d1fe48eb7d65be324 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:56:40 +0300 Subject: [PATCH] fix(bots): sync cross-device group chats and roster from desktop projection (#1006) Co-authored-by: M57 --- .../m57/hermescontrol/data/model/Profile.kt | 86 +++++++- .../hermescontrol/ui/bots/BotsViewModel.kt | 201 ++++++++++++++++-- .../ui/bots/group/GroupChatViewModel.kt | 138 +++++------- .../ui/bots/BotsViewModelTest.kt | 66 ++++++ 4 files changed, 387 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index 1e9e5a93..711ed2b1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -49,6 +49,21 @@ data class ProfileInfo( } } + fun groupChatSyncSnapshot( + json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + }, + ): GroupChatSyncSnapshot? { + val element = ui_meta?.get("hermes-bots-groups") ?: return null + return try { + json.decodeFromJsonElement(element) + } catch (_: Exception) { + null + } + } + val effectiveTitle: String get() = botMeta()?.title?.takeIf { it.isNotBlank() } @@ -104,7 +119,15 @@ data class BotRosterMeta( val groups: List? = null, val group: String? = null, val created: Double? = null, -) +) { + val allGroups: List + get() { + val list = mutableListOf() + groups?.forEach { if (it.isNotBlank()) list.add(it.trim()) } + group?.takeIf { it.isNotBlank() }?.let { if (!list.contains(it.trim())) list.add(it.trim()) } + return list.distinct() + } +} @Serializable data class BotAvatarMeta( @@ -120,7 +143,62 @@ data class GroupChatSyncSnapshot( val updatedAt: Long? = null, val rooms: Map? = null, val deleted: Map? = null, -) +) { + fun toMap(): Map { + val roomsMap = mutableMapOf() + rooms?.forEach { (key, room) -> + val roomMap = mutableMapOf() + room.name?.let { roomMap["name"] = it } + room.roomId?.let { roomMap["roomId"] = it } + room.picture?.let { roomMap["picture"] = it } + room.image?.let { roomMap["image"] = it } + room.updatedAt?.let { roomMap["updatedAt"] = it } + room.createdAt?.let { roomMap["createdAt"] = it } + room.revision?.let { roomMap["revision"] = it } + room.syncRevision?.let { roomMap["syncRevision"] = it } + room.tombstone?.let { roomMap["tombstone"] = it } + room.members?.let { members -> + roomMap["members"] = + members.mapNotNull { + when (it) { + is JsonPrimitive -> it.content + is JsonObject -> it["name"]?.jsonPrimitive?.content ?: it["handle"]?.jsonPrimitive?.content + else -> null + } + } + } + room.log?.let { log -> + roomMap["log"] = + log.map { entry -> + buildMap { + entry.id?.let { put("id", it) } + entry.text?.let { put("text", it) } + entry.at?.let { put("at", it) } + entry.thread?.let { put("thread", it) } + entry.from?.let { f -> + put( + "from", + buildMap { + f.kind?.let { put("kind", it) } + f.name?.let { put("name", it) } + f.source?.let { put("source", it) } + }, + ) + } + } + } + } + roomsMap[key] = roomMap + } + + return buildMap { + put("version", version ?: 3) + put("updatedAt", updatedAt ?: System.currentTimeMillis()) + put("rooms", roomsMap) + deleted?.let { put("deleted", it) } + } + } +} @Serializable data class GroupChatRoomMeta( @@ -132,15 +210,17 @@ data class GroupChatRoomMeta( val image: String? = null, val log: List? = null, val revision: Long? = null, + val syncRevision: Long? = null, val updatedAt: Long? = null, val createdAt: Long? = null, + val tombstone: Boolean? = null, ) { val memberNames: List get() = members?.mapNotNull { el -> when (el) { is JsonPrimitive -> el.content - is JsonObject -> el["name"]?.jsonPrimitive?.content + is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content else -> null } }.orEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index db218902..c1a9764e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.CreateProfileRequest +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement enum class BotsTab { @@ -34,6 +37,7 @@ enum class BotsTab { data class GroupInfo( val name: String, val members: List, + val lastActivity: Long = 0L, ) data class BotsUiState( @@ -52,17 +56,104 @@ data class BotsUiState( val allGroups: List get() { - val groupNames = - profiles - .flatMap { it.botMeta()?.groups.orEmpty() } - .distinct() - .sorted() - return groupNames.map { gName -> + val defaultProfile = + profiles.find { it.is_default == true || it.name == "default" } + ?: profiles.firstOrNull() + val syncSnapshot = defaultProfile?.groupChatSyncSnapshot() + val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() } + + val groupNameMap = linkedMapOf() + + // 1. Collect from bot metadata (both `groups` array and `group` scalar) + for (profile in profiles) { + for (g in profile.botMeta()?.allGroups.orEmpty()) { + val trimmed = g.trim() + if (trimmed.isNotBlank()) { + val lower = trimmed.lowercase() + if (!groupNameMap.containsKey(lower)) { + groupNameMap[lower] = trimmed + } + } + } + } + + // 2. Collect from cross-device snapshot rooms (Desktop parity) + val snapshotRooms = syncSnapshot?.rooms.orEmpty() + for ((key, room) in snapshotRooms) { + if (room.tombstone == true) continue + val roomKeyLower = key.lowercase() + if (deletedKeys.contains(roomKeyLower) || + deletedKeys.contains("name:${room.name?.lowercase()}") || + deletedKeys.contains("id:${room.roomId?.lowercase()}") + ) { + continue + } + val roomName = + room.name?.trim()?.takeIf { it.isNotBlank() } + ?: key.removePrefix("name:").removePrefix("id:").trim() + if (roomName.isNotBlank()) { + val lower = roomName.lowercase() + if (!groupNameMap.containsKey(lower)) { + groupNameMap[lower] = roomName + } + } + } + + return groupNameMap.values.map { gName -> + val lower = gName.lowercase() + val matchingRoom = + snapshotRooms[gName] + ?: snapshotRooms["name:$gName"] + ?: snapshotRooms["id:$gName"] + ?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) } + + val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } + + val matchedProfiles = + profiles.filter { profile -> + val botGroups = profile.botMeta()?.allGroups.orEmpty() + botGroups.any { it.equals(gName, ignoreCase = true) } || + roomMemberNames.contains(profile.name.lowercase()) || + roomMemberNames.contains(profile.effectiveTitle.lowercase()) + }.toMutableList() + + // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated + if (matchingRoom != null && roomMemberNames.isNotEmpty()) { + for (mName in matchingRoom.memberNames) { + val exists = + matchedProfiles.any { + it.name.equals(mName, ignoreCase = true) || + it.effectiveTitle.equals(mName, ignoreCase = true) + } + if (!exists) { + val local = profiles.find { it.name.equals(mName, ignoreCase = true) } + matchedProfiles.add(local ?: ProfileInfo(name = mName)) + } + } + } + + var groupMembers: List = matchedProfiles + + // Fallback: If group name was composed from bot names (e.g. "default, scoutbot") + if (groupMembers.isEmpty() && gName.contains(",")) { + val targetNames = gName.split(",").map { it.trim().lowercase() } + groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) } + } + + val lastAt = + matchingRoom?.log?.lastOrNull()?.at + ?: matchingRoom?.updatedAt + ?: 0L + GroupInfo( name = gName, - members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) }, + members = groupMembers, + lastActivity = lastAt, ) - } + }.sortedWith( + compareByDescending { it.lastActivity } + .thenBy { it.name }, + ) } val displayGroups: List @@ -321,13 +412,15 @@ class BotsViewModel( ) { viewModelScope.launch(ioDispatcher) { val currentProfiles = _uiState.value.profiles + // 1. Update bot metadata on each member bot for (name in botNames) { val bot = currentProfiles.find { it.name == name } ?: continue - val existingGroups = bot.botMeta()?.groups.orEmpty() - if (!existingGroups.contains(groupName)) { + val existingGroups = bot.botMeta()?.allGroups.orEmpty() + if (!existingGroups.any { it.equals(groupName, ignoreCase = true) }) { val updatedMeta = (bot.botMeta() ?: BotRosterMeta()).copy( groups = existingGroups + groupName, + group = existingGroups.firstOrNull() ?: groupName, ) try { wsClientConfigureBot(name, updatedMeta).await() @@ -335,6 +428,47 @@ class BotsViewModel( } } } + + // 2. Also register room in hermes-bots-groups sync snapshot on default profile + try { + val defaultProfile = + currentProfiles.find { it.is_default == true || it.name == "default" } + ?: currentProfiles.firstOrNull() + if (defaultProfile != null) { + val existingSnapshot = + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + val roomKey = "name:$groupName" + val newRoom = + GroupChatRoomMeta( + name = groupName, + members = botNames.map { JsonPrimitive(it) }, + updatedAt = System.currentTimeMillis(), + createdAt = System.currentTimeMillis(), + ) + val updatedRooms = existingSnapshot.rooms.orEmpty() + (roomKey to newRoom) + val updatedDeleted = + existingSnapshot.deleted.orEmpty().filterKeys { + !it.equals(roomKey, ignoreCase = true) && !it.equals(groupName, ignoreCase = true) + } + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = System.currentTimeMillis(), + rooms = updatedRooms, + deleted = updatedDeleted, + ) + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), + ), + ).await() + } + } catch (_: Exception) { + } + loadBots() onSuccess() } @@ -346,12 +480,15 @@ class BotsViewModel( ) { viewModelScope.launch(ioDispatcher) { val currentProfiles = _uiState.value.profiles + // 1. Remove from all member bots' metadata for (bot in currentProfiles) { - val existingGroups = bot.botMeta()?.groups.orEmpty() - if (existingGroups.contains(groupName)) { + val existingGroups = bot.botMeta()?.allGroups.orEmpty() + if (existingGroups.any { it.equals(groupName, ignoreCase = true) }) { + val filtered = existingGroups.filterNot { it.equals(groupName, ignoreCase = true) } val updatedMeta = (bot.botMeta() ?: BotRosterMeta()).copy( - groups = existingGroups - groupName, + groups = filtered, + group = filtered.firstOrNull(), ) try { wsClientConfigureBot(bot.name, updatedMeta).await() @@ -359,6 +496,44 @@ class BotsViewModel( } } } + + // 2. Remove room from hermes-bots-groups snapshot and register tombstone in deleted map + try { + val defaultProfile = + currentProfiles.find { it.is_default == true || it.name == "default" } + ?: currentProfiles.firstOrNull() + if (defaultProfile != null) { + val existingSnapshot = defaultProfile.groupChatSyncSnapshot() + if (existingSnapshot != null) { + val updatedRooms = + existingSnapshot.rooms.orEmpty().filterKeys { key -> + val room = existingSnapshot.rooms?.get(key) + !key.equals(groupName, ignoreCase = true) && + !key.equals("name:$groupName", ignoreCase = true) && + !key.equals("id:$groupName", ignoreCase = true) && + !(room?.name.equals(groupName, ignoreCase = true)) + } + val deletedMap = + existingSnapshot.deleted.orEmpty() + ("name:$groupName" to System.currentTimeMillis()) + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = System.currentTimeMillis(), + rooms = updatedRooms, + deleted = deletedMap, + ) + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), + ), + ).await() + } + } + } catch (_: Exception) { + } + loadBots() onSuccess() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 1a74eaa2..ed73fb4d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -28,10 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonPrimitive import java.util.UUID data class GroupChatUiState( @@ -170,11 +168,44 @@ class GroupChatViewModel( _uiState.update { it.copy(isLoading = true, errorMessage = null) } val allProfiles = fetchProfiles() if (allProfiles.isNotEmpty()) { - var groupMembers = + // Hydrate previous group chat messages from cross-device sync snapshot + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } + ?: allProfiles.firstOrNull() + val syncSnapshot = defaultProfile?.groupChatSyncSnapshot() + + val matchingRoom = + syncSnapshot?.rooms?.get(groupName) + ?: syncSnapshot?.rooms?.get("name:$groupName") + ?: syncSnapshot?.rooms?.get("id:$groupName") + ?: syncSnapshot?.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + + val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } + + val matchedProfiles = allProfiles.filter { - val botGroups = it.botMeta()?.groups.orEmpty() - botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) } + val botGroups = it.botMeta()?.allGroups.orEmpty() + botGroups.any { g -> g.equals(groupName, ignoreCase = true) } || + roomMemberNames.contains(it.name.lowercase()) || + roomMemberNames.contains(it.effectiveTitle.lowercase()) + }.toMutableList() + + // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated + if (matchingRoom != null && roomMemberNames.isNotEmpty()) { + for (mName in matchingRoom.memberNames) { + val exists = + matchedProfiles.any { + it.name.equals(mName, ignoreCase = true) || + it.effectiveTitle.equals(mName, ignoreCase = true) + } + if (!exists) { + val local = allProfiles.find { it.name.equals(mName, ignoreCase = true) } + matchedProfiles.add(local ?: ProfileInfo(name = mName)) + } } + } + + var groupMembers: List = matchedProfiles // Fallback: If group name was composed from bot names (e.g. "default, scoutbot") if (groupMembers.isEmpty() && groupName.contains(",")) { @@ -192,34 +223,19 @@ class GroupChatViewModel( "Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}", ) - // Hydrate previous group chat messages from cross-device sync snapshot - val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" } - val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups") - val syncSnapshot = - syncSnapshotElement?.let { el -> - try { - json.decodeFromJsonElement(el) - } catch (e: Exception) { - Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}") - null - } - } - var initialMessages = _uiState.value.messages if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) { - val matchingRoom = - syncSnapshot.rooms[groupName] - ?: syncSnapshot.rooms["name:$groupName"] - ?: syncSnapshot.rooms["id:$groupName"] - ?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) } - val syncedLog = matchingRoom?.log.orEmpty() if (syncedLog.isNotEmpty()) { initialMessages = syncedLog.map { entry -> val isUser = entry.from?.kind != "member" val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" } - val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) } + val memberProfile = + allProfiles.find { + it.name.equals(senderName, ignoreCase = true) || + it.effectiveTitle.equals(senderName, ignoreCase = true) + } GroupChatMessage( id = entry.id ?: UUID.randomUUID().toString(), senderName = memberProfile?.name ?: senderName, @@ -446,15 +462,9 @@ class GroupChatViewModel( val allProfiles = fetchProfiles() val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch - val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups") val existingSnapshot = - existingElement?.let { - try { - json.decodeFromJsonElement(it) - } catch (_: Exception) { - null - } - } ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) val logEntries = currentMessages.takeLast(32).map { msg -> @@ -472,8 +482,13 @@ class GroupChatViewModel( } val roomKey = "name:$groupName" + val existingRoom = + existingSnapshot.rooms?.get(roomKey) + ?: existingSnapshot.rooms?.get(groupName) + ?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + val updatedRoom = - GroupChatRoomMeta( + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy( name = groupName, members = _uiState.value.members.map { JsonPrimitive(it.name) }, log = logEntries, @@ -488,7 +503,7 @@ class GroupChatViewModel( rooms = updatedRooms, ) - val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot)) + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) HermesWsClient.request( WsMethods.PROFILES_CONFIGURE, @@ -503,59 +518,6 @@ class GroupChatViewModel( } } - private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map { - val roomsMap = mutableMapOf() - snapshot.rooms?.forEach { (key, room) -> - val roomMap = mutableMapOf() - room.name?.let { roomMap["name"] = it } - room.roomId?.let { roomMap["roomId"] = it } - room.picture?.let { roomMap["picture"] = it } - room.image?.let { roomMap["image"] = it } - room.updatedAt?.let { roomMap["updatedAt"] = it } - room.createdAt?.let { roomMap["createdAt"] = it } - room.revision?.let { roomMap["revision"] = it } - room.members?.let { members -> - roomMap["members"] = - members.mapNotNull { - when (it) { - is JsonPrimitive -> it.content - is JsonObject -> it["name"]?.jsonPrimitive?.content - else -> null - } - } - } - room.log?.let { log -> - roomMap["log"] = - log.map { entry -> - buildMap { - entry.id?.let { put("id", it) } - entry.text?.let { put("text", it) } - entry.at?.let { put("at", it) } - entry.thread?.let { put("thread", it) } - entry.from?.let { f -> - put( - "from", - buildMap { - f.kind?.let { put("kind", it) } - f.name?.let { put("name", it) } - f.source?.let { put("source", it) } - }, - ) - } - } - } - } - roomsMap[key] = roomMap - } - - return buildMap { - put("version", snapshot.version ?: 3) - put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis()) - put("rooms", roomsMap) - snapshot.deleted?.let { put("deleted", it) } - } - } - private fun isPass(text: String): Boolean { val clean = text.trim().lowercase() return clean == "(pass)" || clean == "pass" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt index 38c275d2..74777d1f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.CanonicalSessionInfo +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileWorkerSummary import com.m57.hermescontrol.data.model.ProfilesResponse @@ -24,6 +26,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement import org.junit.After import org.junit.Assert.assertEquals @@ -273,4 +276,67 @@ class BotsViewModelTest { assertTrue(success) coVerify(exactly = 1) { mockApi.deleteProfile("scout") } } + + @Test + fun testAllGroups_includesDesktopSyncRoomsAndRespectsOrdering() = + runTest { + val syncSnapshot = + GroupChatSyncSnapshot( + version = 3, + updatedAt = 1725000000L, + rooms = + mapOf( + "name:Desktop Room" to + GroupChatRoomMeta( + name = "Desktop Room", + members = listOf(JsonPrimitive("botA"), JsonPrimitive("remoteBot")), + updatedAt = 1725000100L, + ), + "id:room-123" to + GroupChatRoomMeta( + name = "Old Archived Room", + members = listOf(JsonPrimitive("botA")), + tombstone = true, + ), + ), + deleted = mapOf("name:deleted room" to 1725000000L), + ) + + val defaultProfile = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)), + ) + + val botA = + ProfileInfo( + name = "botA", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(groups = listOf("Local Only Group")), + ), + ), + ) + + coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(listOf(defaultProfile, botA))) + coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + + val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false) + viewModel.loadBots() + advanceUntilIdle() + + val groups = viewModel.uiState.value.allGroups + assertEquals(2, groups.size) + // "Desktop Room" has recent activity 1725000100L, so it should rank first + assertEquals("Desktop Room", groups[0].name) + assertEquals(2, groups[0].members.size) + assertEquals("botA", groups[0].members[0].name) + assertEquals("remoteBot", groups[0].members[1].name) + + assertEquals("Local Only Group", groups[1].name) + assertEquals(1, groups[1].members.size) + } }