Show when a thread is with someone you never added

The app adopts anyone who messages you, because a first message from a
non-contact used to be invisible — no row, no name, just an unread badge over
"Nobody here yet". That fix stays: the message still arrives and is still
readable.

What it also did was decide something for the user. Somebody ends up in your
contacts because they wrote to you, and nothing on screen distinguished that
from a person you went and looked up.

So adoption now sends `auto: true` and leaves the acceptance to the person, and
the thread carries a banner naming what happened with the two useful actions in
it. Nothing is gated: delivery is unchanged, the difference is just visible.

`accepted` defaults to true wherever it is not answered — an older relay says
nothing, and reading silence as "stranger" would label every contact on every
older relay.

The copy states what happened rather than warning about it. Most first messages
are from someone you gave your address to, and opening with an alarm would be
wrong more often than right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 15:47:26 -04:00
co-authored by Claude Opus 4.8
parent 31551f60f2
commit e345d93640
3 changed files with 127 additions and 3 deletions
@@ -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<io.flamenet.client.MessengerApi.Buddy>,
) {
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
@@ -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
}
}