Let people rename a group

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 20:50:50 -04:00
co-authored by Claude Opus 4.8
parent 4a6be0ba6b
commit d128e8d56e
3 changed files with 124 additions and 3 deletions
@@ -2437,6 +2437,8 @@ private fun ColumnScope.GroupMembers(
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
) {
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
@@ -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<Long>, remove: List<Long>) =
viewModelScope.launch {
val current = session ?: return@launch