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
@@ -97,8 +97,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
|||||||
| Photos | ✅ send via the system picker, render inline |
|
| Photos | ✅ send via the system picker, render inline |
|
||||||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||||||
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
|
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
|
||||||
| Groups: create, list, members, leave | ⚪ |
|
| Groups: create, list, members, leave | ✅ members editable after creation |
|
||||||
| Group text and images | ⚪ |
|
| Group text and images | ✅ one upload, one envelope per member |
|
||||||
| Delivery keys + rotation | ✅ published as a digest, rotatable from Account |
|
| Delivery keys + rotation | ✅ published as a digest, rotatable from Account |
|
||||||
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread |
|
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread |
|
||||||
| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork |
|
| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork |
|
||||||
@@ -107,7 +107,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
|||||||
| Presence (read and set) | ✅ shown from the relay, never inferred |
|
| Presence (read and set) | ✅ shown from the relay, never inferred |
|
||||||
| Buddy list, add by username | ✅ |
|
| Buddy list, add by username | ✅ |
|
||||||
| Remove, block, unblock | ✅ |
|
| Remove, block, unblock | ✅ |
|
||||||
| Member lookup | ⚪ |
|
| Member lookup | ✅ look up before adding; adding is gated on it |
|
||||||
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
|
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
|
||||||
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
|
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
|
||||||
| Notifications list, read state | ✅ |
|
| Notifications list, read state | ✅ |
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class ChatFlowTest {
|
|||||||
// person actually has to type.
|
// person actually has to type.
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||||
|
// Adding is gated on a lookup: the button stays disabled until the
|
||||||
|
// handle resolves, so a typo cannot become a contact.
|
||||||
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
|
|
||||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||||
@@ -98,6 +102,10 @@ class ChatFlowTest {
|
|||||||
signUp()
|
signUp()
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||||
|
// Adding is gated on a lookup: the button stays disabled until the
|
||||||
|
// handle resolves, so a typo cannot become a contact.
|
||||||
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class CrossPlatformTest {
|
|||||||
if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) {
|
if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) {
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(peer)
|
compose.onNodeWithTag("new-contact").performTextInput(peer)
|
||||||
|
// Adding is gated on a lookup: the button stays disabled until the
|
||||||
|
// handle resolves, so a typo cannot become a contact.
|
||||||
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
awaitNode(hasText(peer, substring = true), 90_000)
|
awaitNode(hasText(peer, substring = true), 90_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ class CrudSurfaceTest {
|
|||||||
|
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||||
|
// Adding is gated on a successful lookup now: a typo must not silently
|
||||||
|
// become a contact who is a stranger.
|
||||||
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||||
|
|
||||||
@@ -134,6 +138,10 @@ class CrudSurfaceTest {
|
|||||||
signUp()
|
signUp()
|
||||||
compose.onNodeWithTag("add-contact").performClick()
|
compose.onNodeWithTag("add-contact").performClick()
|
||||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||||
|
// Adding is gated on a lookup: the button stays disabled until the
|
||||||
|
// handle resolves, so a typo cannot become a contact.
|
||||||
|
compose.onNodeWithTag("lookup-contact").performClick()
|
||||||
|
awaitTag("lookup-result", 30_000)
|
||||||
compose.onNodeWithTag("confirm-contact").performClick()
|
compose.onNodeWithTag("confirm-contact").performClick()
|
||||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
||||||
|
|||||||
@@ -83,18 +83,58 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
|
|||||||
if (adding) {
|
if (adding) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = username,
|
value = username,
|
||||||
onValueChange = { username = it },
|
onValueChange = {
|
||||||
|
username = it
|
||||||
|
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
|
||||||
|
},
|
||||||
label = { Text("Username") },
|
label = { Text("Username") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
model.addBuddy(username.trim())
|
model.addBuddy(username.trim())
|
||||||
username = ""
|
username = ""
|
||||||
adding = false
|
adding = false
|
||||||
|
model.clearLookup()
|
||||||
},
|
},
|
||||||
enabled = username.isNotBlank() && !phase.busy,
|
enabled = phase.lookedUp != null && !phase.busy,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
|
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
|
||||||
) { Text("Add contact") }
|
) { Text("Add contact") }
|
||||||
}
|
}
|
||||||
@@ -563,7 +603,13 @@ private fun PresenceDot(status: String) {
|
|||||||
|
|
||||||
/** One conversation. */
|
/** One conversation. */
|
||||||
@Composable
|
@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("") }
|
var draft by remember { mutableStateOf("") }
|
||||||
val listState = rememberLazyListState()
|
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
|
// 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.
|
// identities and no single answer, so this is for one-to-one threads only.
|
||||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
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)) {
|
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
val chat = current.openChat
|
val chat = current.openChat
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
ChatScreen(model, chat, current.error)
|
ChatScreen(model, chat, current.error, current.buddies)
|
||||||
} else {
|
} else {
|
||||||
when (current.screen) {
|
when (current.screen) {
|
||||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val stories: List<Vault.Story> = emptyList(),
|
val stories: List<Vault.Story> = emptyList(),
|
||||||
/** The story being viewed full-screen, if any. */
|
/** The story being viewed full-screen, if any. */
|
||||||
val openStory: Vault.Story? = null,
|
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",
|
val presence: String = "online",
|
||||||
/** Which screen is showing. One field, so two cannot be open at once. */
|
/** Which screen is showing. One field, so two cannot be open at once. */
|
||||||
val screen: Screen = Screen.CONTACTS,
|
val screen: Screen = Screen.CONTACTS,
|
||||||
@@ -666,7 +669,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val chat = signedIn()?.openChat ?: return@launch
|
val chat = signedIn()?.openChat ?: return@launch
|
||||||
update { it.copy(busy = true, error = null) }
|
update { it.copy(busy = true, error = null) }
|
||||||
val done = withContext(Dispatchers.IO) {
|
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 ->
|
done.onSuccess { sent ->
|
||||||
update {
|
update {
|
||||||
@@ -681,6 +691,39 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
}.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) ------------------------------------------------------
|
// -- Stories (§6.6) ------------------------------------------------------
|
||||||
|
|
||||||
fun refreshStories() = viewModelScope.launch {
|
fun refreshStories() = viewModelScope.launch {
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ class Session(
|
|||||||
return sent
|
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.
|
* Poll once, filing anything that arrives. Returns who now has new mail.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -158,6 +158,42 @@ class Conversations(
|
|||||||
.copy(attachment = ref)
|
.copy(attachment = ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an image to a group.
|
||||||
|
*
|
||||||
|
* ⚠️ **Sealed and uploaded ONCE, then fanned out** — same reasoning as a
|
||||||
|
* story. One blob and one key, referenced by every member's envelope;
|
||||||
|
* uploading per member would multiply the bytes by the group size and give
|
||||||
|
* the relay a set of blobs it could correlate.
|
||||||
|
*/
|
||||||
|
fun sendGroupAttachment(
|
||||||
|
group: RelayClient.Group,
|
||||||
|
myUserId: Long,
|
||||||
|
data: ByteArray,
|
||||||
|
mime: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
caption: String? = null,
|
||||||
|
): Sent {
|
||||||
|
val sealed = Attachment.seal(data)
|
||||||
|
val id = relay.uploadAttachment(sealed.blob)
|
||||||
|
val ref = Attachment.Ref(
|
||||||
|
kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest,
|
||||||
|
bytes = sealed.blob.size, mime = mime, width = width, height = height,
|
||||||
|
)
|
||||||
|
val body = Content.attachment(ref, caption, group.id)
|
||||||
|
|
||||||
|
val delivered = mutableListOf<String>()
|
||||||
|
val failed = LinkedHashMap<String, String>()
|
||||||
|
for (member in group.memberIds) {
|
||||||
|
if (member == myUserId) continue
|
||||||
|
val one = sendPlaintext(member, body)
|
||||||
|
delivered += one.delivered
|
||||||
|
failed += one.failed
|
||||||
|
}
|
||||||
|
return Sent(delivered, failed, attachment = ref)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post a story to several people at once (§6.6).
|
* Post a story to several people at once (§6.6).
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user