Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.
So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.
Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.
Two things worth stating plainly:
The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.
Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.
Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e65d1d987b
commit
f2d74cff0c
@@ -117,7 +117,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
|
||||
Text(
|
||||
buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (buddy.userId in phase.unread) FontWeight.Bold else null,
|
||||
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
|
||||
FontWeight.Bold else null,
|
||||
)
|
||||
// The handle beside the display name, because the handle is
|
||||
// what you tell someone else to type.
|
||||
@@ -129,7 +130,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (buddy.userId in phase.unread) {
|
||||
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
@@ -157,7 +158,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
|
||||
modifier = Modifier.testTag("block-${buddy.userId}"),
|
||||
) { Text(if (isBlocked) "Unblock" else "Block") }
|
||||
TextButton(
|
||||
onClick = { model.clearHistory(buddy.userId) },
|
||||
onClick = { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) },
|
||||
modifier = Modifier.testTag("clear-${buddy.userId}"),
|
||||
) { Text("Clear chat") }
|
||||
TextButton(
|
||||
@@ -219,6 +220,109 @@ private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerV
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups: list them, make one, open one.
|
||||
*
|
||||
* ⚠️ Member names are resolved here, from the contact list. The relay stores
|
||||
* ids and nothing else on purpose — it has no user table and should not grow
|
||||
* one, because a relay that caches display names is a relay that leaks them.
|
||||
* A member you have never added shows as an id, which is honest.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.GroupsSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var chosen by remember { mutableStateOf(setOf<Long>()) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Groups", style = MaterialTheme.typography.headlineSmall)
|
||||
TextButton(
|
||||
onClick = { creating = !creating },
|
||||
modifier = Modifier.testTag("new-group"),
|
||||
) { Text(if (creating) "Cancel" else "New") }
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Group name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("group-name"),
|
||||
)
|
||||
if (phase.buddies.isEmpty()) {
|
||||
Text("Add a contact first — a group needs somebody in it.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
phase.buddies.forEach { buddy ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
chosen = if (buddy.userId in chosen) chosen - buddy.userId
|
||||
else chosen + buddy.userId
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.testTag("group-member-${buddy.userId}"),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(if (buddy.userId in chosen) "☑" else "☐")
|
||||
Text(buddy.displayName.ifBlank { buddy.handle })
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
model.createGroup(name.trim(), chosen.toList())
|
||||
name = ""; chosen = emptySet(); creating = false
|
||||
},
|
||||
enabled = name.isNotBlank() && chosen.isNotEmpty() && !phase.busy,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create-group"),
|
||||
) { Text("Create group") }
|
||||
}
|
||||
|
||||
phase.groups.forEach { group ->
|
||||
val thread = io.flamenet.e2e.Vault.threadForGroup(group.id)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openGroup(group) }
|
||||
.padding(vertical = 12.dp)
|
||||
.testTag("group-${group.id}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
group.name.ifBlank { "Group" },
|
||||
fontWeight = if (thread in phase.unread) FontWeight.Bold else null,
|
||||
)
|
||||
Text(
|
||||
group.memberIds.joinToString(", ") { id ->
|
||||
phase.buddies.firstOrNull { it.userId == id }
|
||||
?.let { b -> b.displayName.ifBlank { b.handle } }
|
||||
?: "user $id"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (thread in phase.unread) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.testTag("unread-group-${group.id}"))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
/** Presence, which is a claim about you and therefore yours to set. */
|
||||
@Composable
|
||||
fun PresencePicker(model: MessengerViewModel, current: String) {
|
||||
@@ -399,8 +503,20 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = model::closeChat, modifier = Modifier.testTag("back")) { Text("Back") }
|
||||
Text(chat.peerName.ifBlank { "user ${chat.peerId}" },
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(chat.title.ifBlank { "Conversation" },
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
chat.group?.let {
|
||||
Text("${it.memberIds.size} members",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
chat.group?.let { group ->
|
||||
TextButton(
|
||||
onClick = { model.leaveGroup(group.id) },
|
||||
modifier = Modifier.testTag("leave-group"),
|
||||
) { Text("Leave", color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
|
||||
@@ -304,6 +304,7 @@ private fun ColumnScope.SignedIn(
|
||||
|
||||
PresencePicker(model, phase.presence)
|
||||
BuddyList(model, phase)
|
||||
GroupsSection(model, phase)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -39,13 +39,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val relay: String,
|
||||
val status: String,
|
||||
val buddies: List<MessengerApi.Buddy> = emptyList(),
|
||||
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
|
||||
val blocked: Set<Long> = emptySet(),
|
||||
val notifications: List<MessengerApi.Notification> = emptyList(),
|
||||
val presence: String = "online",
|
||||
/** Which screen is showing. One field, so two cannot be open at once. */
|
||||
val screen: Screen = Screen.CONTACTS,
|
||||
/** People with unread mail, so the list can say so without opening each thread. */
|
||||
val unread: Set<Long> = emptySet(),
|
||||
/** Threads with unread mail, so a list can say so without opening each. */
|
||||
val unread: Set<String> = emptySet(),
|
||||
val openChat: Chat? = null,
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
@@ -53,11 +54,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
|
||||
|
||||
/** An open conversation: who with, and everything said so far. */
|
||||
/**
|
||||
* An open conversation.
|
||||
*
|
||||
* One type for both kinds: a thread is a thread, and the only
|
||||
* difference is whether it is addressed to a person or a group. Two
|
||||
* types would mean two of everything downstream.
|
||||
*/
|
||||
data class Chat(
|
||||
val peerId: Long,
|
||||
val peerName: String,
|
||||
val threadId: String,
|
||||
val title: String,
|
||||
val lines: List<Session.Line>,
|
||||
val peerId: Long? = null,
|
||||
val group: io.flamenet.client.RelayClient.Group? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -123,6 +132,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
relay = SiteConfig(credential.relayBaseUrl).host,
|
||||
status = "Signed in on this device.",
|
||||
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
|
||||
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +244,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
relay = config.host,
|
||||
status = "Registered with ${preKeys.size} one-time prekeys published.",
|
||||
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
|
||||
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,18 +278,94 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun openChat(peerId: Long, peerName: String) {
|
||||
val current = session ?: return
|
||||
val thread = Vault.threadForUser(peerId)
|
||||
update {
|
||||
it.copy(
|
||||
openChat = Phase.Chat(peerId, peerName, current.history(peerId)),
|
||||
openChat = Phase.Chat(thread, peerName, current.history(thread), peerId = peerId),
|
||||
// Opening the thread is what marks it read. Nothing is sent to
|
||||
// the relay for this — read state is local, because a read
|
||||
// receipt is a disclosure and this product does not make it by
|
||||
// default.
|
||||
unread = it.unread - peerId,
|
||||
unread = it.unread - thread,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openGroup(group: io.flamenet.client.RelayClient.Group) {
|
||||
val current = session ?: return
|
||||
val thread = Vault.threadForGroup(group.id)
|
||||
update {
|
||||
it.copy(
|
||||
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group),
|
||||
unread = it.unread - thread,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Groups --------------------------------------------------------------
|
||||
|
||||
fun refreshGroups() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val fetched = withContext(Dispatchers.IO) { runCatching { current.relay.listGroups() } }
|
||||
fetched.onSuccess { list -> update { it.copy(groups = list, error = null) } }
|
||||
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
||||
}
|
||||
|
||||
fun createGroup(name: String, memberIds: List<Long>) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val made = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.relay.createGroup(name, memberIds)
|
||||
current.relay.listGroups()
|
||||
}
|
||||
}
|
||||
made.onSuccess { list -> update { it.copy(busy = false, groups = list) } }
|
||||
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
fun updateGroupMembers(groupId: String, add: List<Long>, remove: List<Long>) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.relay.updateGroupMembers(groupId, add, remove)
|
||||
current.relay.listGroups()
|
||||
}
|
||||
}
|
||||
done.onSuccess { list ->
|
||||
update { state ->
|
||||
state.copy(
|
||||
busy = false, groups = list,
|
||||
openChat = state.openChat?.let { chat ->
|
||||
chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group)
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Leaving also clears the local thread. Staying would leave a
|
||||
* conversation nobody can add to and no way to remove it — a dead thread
|
||||
* the user cannot get rid of.
|
||||
*/
|
||||
fun leaveGroup(groupId: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.relay.leaveGroup(groupId)
|
||||
current.clearHistory(Vault.threadForGroup(groupId))
|
||||
current.relay.listGroups()
|
||||
}
|
||||
}
|
||||
done.onSuccess { list ->
|
||||
update { it.copy(busy = false, groups = list, openChat = null) }
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
fun closeChat() = update { it.copy(openChat = null) }
|
||||
|
||||
fun showScreen(screen: Phase.Screen) = update { it.copy(screen = screen, error = null) }
|
||||
@@ -309,11 +396,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* would make the next message from that person undecryptable — a "clear
|
||||
* chat" that quietly breaks the chat.
|
||||
*/
|
||||
fun clearHistory(peerUserId: Long) = viewModelScope.launch {
|
||||
fun clearHistory(threadId: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { current.clearHistory(peerUserId) }
|
||||
withContext(Dispatchers.IO) { current.clearHistory(threadId) }
|
||||
update {
|
||||
it.copy(openChat = it.openChat?.takeIf { chat -> chat.peerId == peerUserId }
|
||||
it.copy(openChat = it.openChat?.takeIf { chat -> chat.threadId == threadId }
|
||||
?.copy(lines = emptyList()) ?: it.openChat)
|
||||
}
|
||||
}
|
||||
@@ -424,13 +511,13 @@ 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 { current.sendImage(chat.peerId!!, bytes, mime, width, height) }
|
||||
}
|
||||
done.onSuccess { sent ->
|
||||
update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
|
||||
error = if (sent.failed.isEmpty()) null
|
||||
else "Delivered to ${sent.delivered.size} of " +
|
||||
"${sent.delivered.size + sent.failed.size} devices.",
|
||||
@@ -462,11 +549,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
if (text.isBlank()) return@launch
|
||||
|
||||
val result = withContext(Dispatchers.IO) { runCatching { current.send(chat.peerId, text) } }
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val group = chat.group
|
||||
if (group != null) current.sendGroup(group, text)
|
||||
else current.send(chat.peerId!!, text)
|
||||
}
|
||||
}
|
||||
result.onSuccess { sent ->
|
||||
update {
|
||||
it.copy(
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
|
||||
// Partial delivery is stated, not hidden. "Sent" when one
|
||||
// of someone's three devices got it is a lie the user acts
|
||||
// on.
|
||||
@@ -492,21 +585,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
while (true) {
|
||||
val current = session
|
||||
if (current != null) {
|
||||
val senders = withContext(Dispatchers.IO) {
|
||||
val threads = withContext(Dispatchers.IO) {
|
||||
runCatching { current.receive() }.getOrDefault(emptySet())
|
||||
}
|
||||
if (senders.isNotEmpty()) {
|
||||
if (threads.isNotEmpty()) {
|
||||
update { state ->
|
||||
val open = state.openChat
|
||||
state.copy(
|
||||
openChat = open?.let {
|
||||
if (it.peerId in senders) {
|
||||
it.copy(lines = current.history(it.peerId))
|
||||
if (it.threadId in threads) {
|
||||
it.copy(lines = current.history(it.threadId))
|
||||
} else it
|
||||
},
|
||||
// A thread that is already open is being read,
|
||||
// so it does not also become unread.
|
||||
unread = state.unread + senders - setOfNotNull(open?.peerId),
|
||||
unread = state.unread + threads - setOfNotNull(open?.threadId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ class Session(
|
||||
val attachment: Attachment.Ref? = null,
|
||||
)
|
||||
|
||||
fun history(peerUserId: Long): List<Line> {
|
||||
val stored = vault.history(peerUserId)
|
||||
fun history(threadId: String): List<Line> {
|
||||
val stored = vault.history(threadId)
|
||||
return (0 until stored.length()).map {
|
||||
val m = stored.getJSONObject(it)
|
||||
Line(
|
||||
@@ -57,19 +57,21 @@ class Session(
|
||||
}
|
||||
|
||||
fun record(
|
||||
peerUserId: Long,
|
||||
threadId: String,
|
||||
text: String,
|
||||
mine: Boolean,
|
||||
attachment: Attachment.Ref? = null,
|
||||
author: Long = 0,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
vault.append(peerUserId, JSONObject()
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("author", author)
|
||||
.put("text", text).put("mine", mine).put("at", at)
|
||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
|
||||
}
|
||||
|
||||
/** Clear a thread. Keeps the session, so the conversation still works. */
|
||||
fun clearHistory(peerUserId: Long) = vault.forgetHistory(peerUserId)
|
||||
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
|
||||
|
||||
/** Send an image. Returns what happened, same as a text send. */
|
||||
fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int):
|
||||
@@ -79,7 +81,7 @@ class Session(
|
||||
if (sent.reachedSomeone) {
|
||||
// From the reference we just built, so the sender's own thread
|
||||
// shows the image without fetching it back from the relay.
|
||||
record(peerUserId, "", mine = true, attachment = sent.attachment)
|
||||
record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
@@ -103,7 +105,21 @@ class Session(
|
||||
*/
|
||||
fun send(peerUserId: Long, text: String): Conversations.Sent {
|
||||
val sent = conversations.send(peerUserId, text)
|
||||
if (sent.reachedSomeone) record(peerUserId, text, mine = true)
|
||||
if (sent.reachedSomeone) record(Vault.threadForUser(peerUserId), text, mine = true)
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to a group, and file our own copy.
|
||||
*
|
||||
* ⚠️ Recorded even when nobody was reachable — a group of one, or a group
|
||||
* whose other members have no devices yet, is a real situation and losing
|
||||
* what you typed in it is not acceptable. The delivery counts still tell
|
||||
* the caller what happened.
|
||||
*/
|
||||
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||
val sent = conversations.sendToGroup(group, text, userId)
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId)
|
||||
return sent
|
||||
}
|
||||
|
||||
@@ -115,11 +131,20 @@ class Session(
|
||||
* devices sent — travel down the same channel, and filing them would put
|
||||
* raw JSON in the thread.
|
||||
*/
|
||||
fun receive(): Set<Long> {
|
||||
fun receive(): Set<String> {
|
||||
val arrived = conversations.poll().filter { it.content.isRenderable }
|
||||
arrived.forEach {
|
||||
record(it.fromUser, it.content.text, mine = false, attachment = it.content.attachment)
|
||||
val touched = mutableSetOf<String>()
|
||||
for (message in arrived) {
|
||||
// ⚠️ A group message is filed under the GROUP, not the sender.
|
||||
// Filing by sender scatters one conversation across as many threads
|
||||
// as it has participants.
|
||||
val thread = message.content.groupId
|
||||
?.let(Vault::threadForGroup)
|
||||
?: Vault.threadForUser(message.fromUser)
|
||||
record(thread, message.content.text, mine = false,
|
||||
attachment = message.content.attachment, author = message.fromUser)
|
||||
touched += thread
|
||||
}
|
||||
return arrived.map { it.fromUser }.toSet()
|
||||
return touched
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user