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:
co-authored by
Claude Opus 4.8
parent
31551f60f2
commit
e345d93640
@@ -1400,6 +1400,80 @@ private fun PresenceDot(status: String) {
|
|||||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour))
|
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. */
|
/** One conversation. */
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@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) }
|
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
|
||||||
|
|||||||
@@ -973,6 +973,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
}.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. */
|
/** Relay vocabulary -> the words on the presence chips. */
|
||||||
internal fun displayPresence(stored: String): String =
|
internal fun displayPresence(stored: String): String =
|
||||||
if (stored == "invisible") "offline" else stored
|
if (stored == "invisible") "offline" else stored
|
||||||
@@ -1270,7 +1285,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
if (id in blocked) return@runCatching
|
if (id in blocked) return@runCatching
|
||||||
val who = current.api.userInfo(id) ?: return@runCatching
|
val who = current.api.userInfo(id) ?: return@runCatching
|
||||||
if (who.handle.isNotBlank()) {
|
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
|
added = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ class MessengerApi(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val handle: String,
|
val handle: String,
|
||||||
val presence: 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(
|
data class Notification(
|
||||||
@@ -66,6 +76,7 @@ class MessengerApi(
|
|||||||
displayName = b.optString("display_name", ""),
|
displayName = b.optString("display_name", ""),
|
||||||
handle = b.optString("screen_name", ""),
|
handle = b.optString("screen_name", ""),
|
||||||
presence = b.optString("presence", "offline"),
|
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
|
* thing that can turn that into an id, and doing the lookup client-side
|
||||||
* would need a directory this product deliberately does not have.
|
* 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user