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 baee42b..3e514be 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1400,6 +1400,80 @@ private fun PresenceDot(status: String) { Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour)) } +/** + * "This person is not in your contacts", with the two things worth offering. + * + * 🔴 **The message has already arrived, and that is on purpose.** Hiding a + * first message behind an accept/ignore gate is the obvious design and it is + * the one that shipped as a bug here: for a week, a first message from anyone + * not already in your contacts was completely invisible — no row, no name, + * just an unread badge over "Nobody here yet". The app now adopts a stranger so + * their message can be seen at all. + * + * ⚠️ But adoption makes a decision for the user: somebody is in your contacts + * because they wrote to you, which is not the same as you choosing them. This + * is the seam — delivery unchanged, the difference made visible, and the choice + * handed back. + * + * ⚠️ Absent for anybody accepted, which after the relay's backfill is every + * contact that existed before this shipped. A banner on an established + * conversation would be noise, and noise is how a safety label stops being + * read. + */ +@Composable +private fun ColumnScope.StrangerBanner( + model: MessengerViewModel, + peerId: Long, + buddies: List, +) { + val row = buddies.firstOrNull { it.userId == peerId } ?: return + if (row.accepted) return + + val name = row.displayName.ifBlank { row.handle.ifBlank { "This person" } } + + Surface( + color = Palette.SurfaceAlt, + shape = RoundedCornerShape(Radius.medium), + modifier = Modifier.fillMaxWidth().testTag("stranger-banner"), + ) { + Column(modifier = Modifier.padding(Space.md)) { + Text( + "$name isn't in your contacts", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + // Says what happened rather than warning about it. Most first + // messages are somebody you gave your address to, and opening + // with an alarm would be wrong far more often than right. + "They messaged you, so they were added automatically. You can keep them " + + "or stop them reaching you.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { + KitPrimaryButton( + text = "Keep as contact", + onClick = { model.acceptCorrespondent(peerId) }, + modifier = Modifier.weight(1f), + tag = "accept-stranger", + ) + KitSecondaryButton( + text = "Block", + onClick = { + model.setBlocked(peerId, true) + model.closeChat() + }, + modifier = Modifier.weight(1f), + danger = true, + tag = "block-stranger", + ) + } + } + } +} + /** One conversation. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -1496,6 +1570,11 @@ fun ColumnScope.ChatScreen( } } + // 🔴 **Above everything else in the thread**, because it is a statement + // about who you are talking to and it has to be read before the messages + // are, not after scrolling past them. + if (chat.peerId != null) StrangerBanner(model, chat.peerId, buddies) + chat.group?.let { group -> GroupMembers(model, group, buddies) } // Identity, directly under the name it belongs to. Groups have many 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 b76d092..5817c8c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -973,6 +973,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } + /** + * Accept a correspondent the app adopted on the user's behalf. + * + * ⚠️ Refreshes the roster rather than patching the row locally: `accepted` + * is the relay's fact, and a second device needs to see the same answer. + */ + fun acceptCorrespondent(userId: Long) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { current.api.acceptConversation(userId) } + } + done.onSuccess { refreshBuddies() } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + /** Relay vocabulary -> the words on the presence chips. */ internal fun displayPresence(stored: String): String = if (stored == "invisible") "offline" else stored @@ -1270,7 +1285,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { if (id in blocked) return@runCatching val who = current.api.userInfo(id) ?: return@runCatching if (who.handle.isNotBlank()) { - current.api.addBuddy(who.handle) + // 🔴 `auto = true`: this add is the app's doing, not + // the user's. It keeps the stranger's first message + // visible without deciding on their behalf that the + // sender is a contact — the thread shows the choice + // instead. + current.api.addBuddy(who.handle, auto = true) added = true } } diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index e8741dc..8335d9f 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -44,6 +44,16 @@ class MessengerApi( val displayName: String, val handle: String, val presence: String, + /** + * Whether this person is somebody the user chose, or somebody who + * turned up because they wrote first. + * + * 🔴 **Defaults to true, and that default is the compatibility story.** + * A relay that predates this field says nothing about acceptance, and + * the only safe reading of silence is "these are your contacts" — the + * alternative labels every contact on every older relay a stranger. + */ + val accepted: Boolean = true, ) data class Notification( @@ -66,6 +76,7 @@ class MessengerApi( displayName = b.optString("display_name", ""), handle = b.optString("screen_name", ""), presence = b.optString("presence", "offline"), + accepted = b.optBoolean("accepted", true), ) } } @@ -75,8 +86,22 @@ class MessengerApi( * thing that can turn that into an id, and doing the lookup client-side * would need a directory this product deliberately does not have. */ - fun addBuddy(username: String) { - post("buddies", JSONObject().put("username", username)) + /** + * @param auto true when the app is adopting somebody who messaged first, + * rather than the user choosing to add them. + * + * 🔴 The distinction is the whole feature. Adopting a stranger keeps their + * first message visible — which is why adoption exists — while leaving the + * decision about whether they are a contact to the person, instead of + * making it for them silently. + */ + fun addBuddy(username: String, auto: Boolean = false) { + post("buddies", JSONObject().put("username", username).put("auto", auto)) + } + + /** Record that the user has accepted this correspondent. */ + fun acceptConversation(userId: Long) { + post("conversation", JSONObject().put("action", "accept").put("buddy", userId)) } /**