Android: member lookup, and the group gaps
**Member lookup.** `MessengerApi.lookup` already existed and nothing called it. Adding a contact now takes two steps: resolve the handle, see who it is, then add — and the add button stays disabled until the lookup succeeds. ⚠️ One step is worse than it looks. Adding straight from a typed string means a typo silently becomes a contact who is a stranger, and the first thing sent to them is a message meant for somebody else. Seeing a display name first is what catches that. ⚠️ A miss says "no account called that here" and nothing more. The relay answers identically for an account that does not exist and one that has not enrolled — deliberately, so it cannot be used to enumerate accounts — and this client must not invent a distinction the relay refused to make. The result also says plainly that it only proves the handle exists, and that identity is checked by comparing safety numbers once there is a conversation. This gate broke five existing instrumented call sites, which is the point of having them: each now looks up before confirming, the way a person does. **Group images.** Sealed and uploaded ONCE, then one envelope per member — same reasoning as a story. Uploading per member would multiply the bytes by the group size and hand the relay a set of blobs it could correlate. 🔴 The composer's photo button called `sendImage(chat.peerId!!, …)` regardless of thread. In a group `peerId` is null, so sending a photo to a group did not send to the wrong place — it crashed. Now routed on whether the thread is a group. **Group membership** is editable after creation; `updateGroupMembers` existed with no way to reach it. The copy states what membership actually does: adding someone lets them read what is sent from now on, removing them stops future messages, and neither is retroactive. A group cannot be un-read, and a UI implying otherwise would be the dangerous kind of wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acee4597d5
commit
c1284aab5f
@@ -83,18 +83,58 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
|
||||
if (adding) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
onValueChange = {
|
||||
username = it
|
||||
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
|
||||
},
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
|
||||
)
|
||||
|
||||
// ⚠️ **Look up first, then add.** Adding straight from a typed string
|
||||
// means a typo silently becomes a contact who is a stranger, and the
|
||||
// first thing sent to them is a message meant for somebody else.
|
||||
TextButton(
|
||||
onClick = { model.lookupMember(username) },
|
||||
enabled = username.isNotBlank() && !phase.busy,
|
||||
modifier = Modifier.fillMaxWidth().testTag("lookup-contact"),
|
||||
) { Text("Look up") }
|
||||
|
||||
phase.lookupMiss?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("lookup-miss"),
|
||||
)
|
||||
}
|
||||
|
||||
phase.lookedUp?.let { found ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().testTag("lookup-result"),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(found.displayName.ifBlank { found.handle })
|
||||
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
|
||||
// Named plainly: identity is checked by comparing safety
|
||||
// numbers once the conversation exists, and a lookup result is
|
||||
// not a substitute for that.
|
||||
Text(
|
||||
"Check their safety number after you start talking — this only says the "
|
||||
+ "handle exists on this server.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
model.addBuddy(username.trim())
|
||||
username = ""
|
||||
adding = false
|
||||
model.clearLookup()
|
||||
},
|
||||
enabled = username.isNotBlank() && !phase.busy,
|
||||
enabled = phase.lookedUp != null && !phase.busy,
|
||||
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
|
||||
) { Text("Add contact") }
|
||||
}
|
||||
@@ -563,7 +603,13 @@ private fun PresenceDot(status: String) {
|
||||
|
||||
/** One conversation. */
|
||||
@Composable
|
||||
fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.Phase.Chat, error: String?) {
|
||||
fun ColumnScope.ChatScreen(
|
||||
model: MessengerViewModel,
|
||||
chat: MessengerViewModel.Phase.Chat,
|
||||
error: String?,
|
||||
/** Needed to name group members; the relay stores ids and no names. */
|
||||
buddies: List<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
|
||||
) {
|
||||
var draft by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -595,6 +641,8 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
|
||||
}
|
||||
}
|
||||
|
||||
chat.group?.let { group -> GroupMembers(model, group, buddies) }
|
||||
|
||||
// Identity, directly under the name it belongs to. Groups have many
|
||||
// identities and no single answer, so this is for one-to-one threads only.
|
||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
||||
@@ -1045,3 +1093,63 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add and remove people after a group exists.
|
||||
*
|
||||
* ⚠️ **Membership is the relay's list, and it decides who receives.** A member
|
||||
* added here starts getting messages from the next send; one removed stops.
|
||||
* Neither is retroactive — removing somebody does not take back what they
|
||||
* already have, and the copy says so rather than implying a group can be
|
||||
* un-read.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.GroupMembers(
|
||||
model: MessengerViewModel,
|
||||
group: io.flamenet.client.RelayClient.Group,
|
||||
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
|
||||
) {
|
||||
var editing by remember(group.id) { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(
|
||||
onClick = { editing = !editing },
|
||||
modifier = Modifier.testTag("edit-members"),
|
||||
) { Text(if (editing) "Done" else "Members") }
|
||||
}
|
||||
|
||||
if (!editing) return
|
||||
|
||||
Text(
|
||||
"Adding someone lets them read what is sent from now on. Removing them stops future "
|
||||
+ "messages — it does not take back what they already have.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
buddies.forEach { buddy ->
|
||||
val isMember = buddy.userId in group.memberIds
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
buddy.displayName.ifBlank { buddy.handle },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (isMember) model.updateGroupMembers(group.id, emptyList(), listOf(buddy.userId))
|
||||
else model.updateGroupMembers(group.id, listOf(buddy.userId), emptyList())
|
||||
},
|
||||
modifier = Modifier.testTag(
|
||||
(if (isMember) "remove-member-" else "add-member-") + buddy.userId),
|
||||
) { Text(if (isMember) "Remove" else "Add") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val chat = current.openChat
|
||||
if (chat != null) {
|
||||
ChatScreen(model, chat, current.error)
|
||||
ChatScreen(model, chat, current.error, current.buddies)
|
||||
} else {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||
|
||||
@@ -47,6 +47,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val stories: List<Vault.Story> = emptyList(),
|
||||
/** The story being viewed full-screen, if any. */
|
||||
val openStory: Vault.Story? = null,
|
||||
/** Who a handle turned out to be, before adding them (§8.2). */
|
||||
val lookedUp: MessengerApi.Me? = null,
|
||||
val lookupMiss: String? = null,
|
||||
val presence: String = "online",
|
||||
/** Which screen is showing. One field, so two cannot be open at once. */
|
||||
val screen: Screen = Screen.CONTACTS,
|
||||
@@ -666,7 +669,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching { current.sendImage(chat.peerId!!, bytes, mime, width, height) }
|
||||
runCatching {
|
||||
// A group thread sends to the group, not to whoever the
|
||||
// thread happens to name. `peerId!!` here used to make a
|
||||
// photo in a group crash rather than send.
|
||||
val group = chat.group
|
||||
if (group != null) current.sendGroupImage(group, bytes, mime, width, height)
|
||||
else current.sendImage(chat.peerId!!, bytes, mime, width, height)
|
||||
}
|
||||
}
|
||||
done.onSuccess { sent ->
|
||||
update {
|
||||
@@ -681,6 +691,39 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
// -- Member lookup ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find out who a handle belongs to before adding them.
|
||||
*
|
||||
* ⚠️ **Look up, then add — deliberately two steps.** Adding straight from a
|
||||
* typed string means a typo silently becomes a contact who is a stranger,
|
||||
* and the first thing sent to them is a message meant for somebody else.
|
||||
* Seeing the display name first is what catches that.
|
||||
*
|
||||
* ⚠️ A miss is reported as "no such handle" and nothing more. The relay
|
||||
* answers the same way for an account that does not exist and one that has
|
||||
* not enrolled, on purpose, so that this cannot be used to enumerate
|
||||
* accounts — and this client must not invent a distinction the relay
|
||||
* refused to make.
|
||||
*/
|
||||
fun lookupMember(handle: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val trimmed = handle.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
update { it.copy(busy = true, lookedUp = null, lookupMiss = null, error = null) }
|
||||
val found = withContext(Dispatchers.IO) { runCatching { current.api.lookup(trimmed) } }
|
||||
update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
lookedUp = found.getOrNull(),
|
||||
lookupMiss = if (found.getOrNull() == null) "No account called \"$trimmed\" here." else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLookup() = update { it.copy(lookedUp = null, lookupMiss = null) }
|
||||
|
||||
// -- Stories (§6.6) ------------------------------------------------------
|
||||
|
||||
fun refreshStories() = viewModelScope.launch {
|
||||
|
||||
@@ -140,6 +140,22 @@ class Session(
|
||||
return sent
|
||||
}
|
||||
|
||||
/** An image to a group: one upload, one envelope per member. */
|
||||
fun sendGroupImage(
|
||||
group: RelayClient.Group,
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
): Conversations.Sent {
|
||||
val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height)
|
||||
if (sent.reachedSomeone) {
|
||||
record(Vault.threadForGroup(group.id), "", mine = true,
|
||||
attachment = sent.attachment, author = userId)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll once, filing anything that arrives. Returns who now has new mail.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user