From d128e8d56e3926a90519b9242a94c33dfbbdd59a Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 20:50:50 -0400 Subject: [PATCH] Let people rename a group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names used to be fixed at creation and served by the relay for ever, so no client ever needed this. The relay has now dropped them — and that makes every group created before the change read as the placeholder on every phone, with 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. This is the repair, and it is the first time a group's name has been changeable at all. 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 pick it up from the next message, which carries it. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/ChatScreens.kt | 41 +++++++++++++++-- .../messenger/app/MessengerViewModel.kt | 40 ++++++++++++++++ .../io/flamenet/client/GroupNameTest.kt | 46 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) 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