diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 916452a..a802b0c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2437,6 +2437,8 @@ private fun ColumnScope.GroupMembers( buddies: List, ) { var editing by remember(group.id) { mutableStateOf(false) } + var renaming by remember(group.id) { mutableStateOf(false) } + var draftName by remember(group.id) { mutableStateOf(group.name) } Row( modifier = Modifier.fillMaxWidth(), @@ -2444,10 +2446,43 @@ private fun ColumnScope.GroupMembers( verticalAlignment = Alignment.CenterVertically, ) { Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall) + Row { + // 🔴 **The only way a group gets a name at all after the first + // one.** Names used to be fixed at creation and served by the + // relay; the relay has stopped holding them, so every group made + // before that change reads as a placeholder on every phone and + // there is nobody left to ask what it was called. + TextButton( + onClick = { draftName = group.name; renaming = !renaming }, + modifier = Modifier.testTag("rename-group"), + ) { Text(if (renaming) "Cancel" else "Rename") } + TextButton( + onClick = { editing = !editing }, + modifier = Modifier.testTag("edit-members"), + ) { Text(if (editing) "Done" else "Members") } + } + } + + if (renaming) { + OutlinedTextField( + value = draftName, + onValueChange = { draftName = it }, + label = { Text("Group name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("group-name-field"), + ) + Text( + "Everyone in the group sees the new name. The server never does — it holds no " + + "group names at all.", + style = MaterialTheme.typography.bodySmall, + ) TextButton( - onClick = { editing = !editing }, - modifier = Modifier.testTag("edit-members"), - ) { Text(if (editing) "Done" else "Members") } + onClick = { + model.renameGroup(group.id, draftName) + renaming = false + }, + modifier = Modifier.testTag("save-group-name"), + ) { Text("Save") } } if (!editing) return diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index ca47e6b..54a631d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -946,6 +946,46 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } + /** + * Rename a group, and tell the other members. + * + * 🔴 **The only way a group gets its name back after the relay stopped + * holding one.** Names were fixed at creation and served by the relay for + * ever, so nothing needed this. Now the server has dropped them, every + * group that existed before the change reads as the placeholder on every + * phone, and there is nobody left to ask what it used to be called — the + * relay deleted its copy and no client had ever kept one. Without a rename + * those groups are called "Group" permanently. + * + * ⚠️ Stored first, announced second. The name is ours the moment we set it, + * so a member who cannot be reached now does not stop us seeing the group + * under the name we chose; they get it from the next message, which carries + * it. + */ + fun renameGroup(groupId: String, name: String) = viewModelScope.launch { + val current = session ?: return@launch + val trimmed = name.trim() + if (trimmed.isEmpty()) return@launch + val done = withContext(Dispatchers.IO) { + runCatching { + current.nameGroup(groupId, trimmed) + val members = current.namedGroups().firstOrNull { it.id == groupId }?.memberIds + if (members != null) current.announceGroupName(groupId, members) + current.namedGroups() + } + } + done.onSuccess { list -> + update { state -> + state.copy( + groups = list, + openChat = state.openChat?.let { chat -> + chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group) + }, + ) + } + }.onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + fun updateGroupMembers(groupId: String, add: List, remove: List) = viewModelScope.launch { val current = session ?: return@launch diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 0b96ff7..ec85690 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -186,6 +186,52 @@ class GroupNameTest { "the delivery key was lost because the envelope also named a group") } + // -- Renaming -------------------------------------------------------------- + + /** + * 🔴 **Renaming is how a group gets its name back.** Every group that + * existed before the relay dropped its names reads as the placeholder on + * every device, and nothing can recover the old one: the relay deleted its + * copy and no client had ever stored one. Names were previously fixed at + * creation, so this path did not exist and had to be built. + */ + @Test + fun `a rename is stored and announced to the members`() { + val vault = vault() + vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 3 }, published = true)) + vault.saveGroupName("g1", "Group") + val conversations = conversations(vault) + + vault.saveGroupName("g1", "Book club, Tuesdays") + assertEquals("Book club, Tuesdays", vault.groupName("g1")) + + // ⚠️ Asserted on the envelope rather than on delivery: the announcement + // is best-effort per member, and a test that required a member to be + // reachable would be testing the fixture's relay, not the rename. + val wire = SealedSender.deliveryKeyEnvelope( + ByteArray(32), groupId = "g1", groupName = vault.groupName("g1")!!) + assertEquals("Book club, Tuesdays", JSONObject(wire).optString("gname")) + assertTrue(conversations.takeClaimedGroupNames().isEmpty()) + } + + /** + * ⚠️ **An announcement with no name is not sent at all.** A group we have + * never been told the name of has nothing to announce, and an envelope + * carrying an empty `gname` would let a member overwrite a good name with + * a blank one. + */ + @Test + fun `announcing a group with no known name sends nothing`() { + val vault = vault() + vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 3 }, published = true)) + val conversations = conversations(vault) + + // No throw, no request: it returns before building anything. If it did + // send, the MockWebServer with no queued response would fail the call. + conversations.announceGroupName("unknown-gid", listOf(2L)) + assertEquals(0, server.requestCount) + } + // -- Where it is kept ------------------------------------------------------ @Test