Off-the-record conversations: the control
A switch in the conversation's own menu, and a line under the header saying so while it is on. The switch asks; it does not assert. The state shown is re-read from what the peer has echoed, and the echo arrives during a poll rather than while the menu is open, so the open conversation re-reads it there too. Without that the banner would claim the conversation was off the record from the moment it was asked for. Group and remote-address conversations have no peer id, and this is a one-to-one setting, so it reads false for them rather than pretending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
140b872c66
commit
f325b1c6ae
@@ -3609,6 +3609,17 @@ private fun ConversationMenu(
|
|||||||
expanded = open,
|
expanded = open,
|
||||||
onDismissRequest = { open = false },
|
onDismissRequest = { open = false },
|
||||||
) {
|
) {
|
||||||
|
MenuHeading("Off the record")
|
||||||
|
MenuChoice(
|
||||||
|
if (chat.offTheRecord) "On, nothing is written down" else "Off",
|
||||||
|
selected = chat.offTheRecord,
|
||||||
|
tag = "otr-toggle",
|
||||||
|
) {
|
||||||
|
model.setOffTheRecord(!chat.offTheRecord)
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.material3.HorizontalDivider()
|
||||||
MenuHeading("Disappearing messages")
|
MenuHeading("Disappearing messages")
|
||||||
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
|
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
|
||||||
MenuChoice(
|
MenuChoice(
|
||||||
@@ -3706,6 +3717,7 @@ private fun ColumnScope.ConversationStatusLine(
|
|||||||
DISAPPEAR_LABELS[chat.disappearSeconds]?.let {
|
DISAPPEAR_LABELS[chat.disappearSeconds]?.let {
|
||||||
add("disappearing after $it, on both phones")
|
add("disappearing after $it, on both phones")
|
||||||
}
|
}
|
||||||
|
if (chat.offTheRecord) add("off the record, nothing is written down")
|
||||||
if (snoozed) add("snoozed")
|
if (snoozed) add("snoozed")
|
||||||
if (chat.replyReminderAt != 0L) add("reminder set")
|
if (chat.replyReminderAt != 0L) add("reminder set")
|
||||||
if (chat.scheduled.isNotEmpty()) {
|
if (chat.scheduled.isNotEmpty()) {
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ class MessengerViewModel(
|
|||||||
val replyReminderAt: Long = 0,
|
val replyReminderAt: Long = 0,
|
||||||
/** Messages queued to go out later: when, and what. Soonest first. */
|
/** Messages queued to go out later: when, and what. Soonest first. */
|
||||||
val scheduled: List<Pair<Long, String>> = emptyList(),
|
val scheduled: List<Pair<Long, String>> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Held in memory by both sides and written to disk by neither.
|
||||||
|
* False until the other side has echoed the proposal back.
|
||||||
|
*/
|
||||||
|
val offTheRecord: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -714,6 +719,7 @@ class MessengerViewModel(
|
|||||||
replyReminderAt = current.replyReminder(peerId),
|
replyReminderAt = current.replyReminder(peerId),
|
||||||
scheduled = current.scheduledFor(peerId),
|
scheduled = current.scheduledFor(peerId),
|
||||||
pinnedMessageId = current.pinnedMessage(thread),
|
pinnedMessageId = current.pinnedMessage(thread),
|
||||||
|
offTheRecord = current.offTheRecord(peerId),
|
||||||
),
|
),
|
||||||
// Opening the thread is what marks it read.
|
// Opening the thread is what marks it read.
|
||||||
unread = it.unread - thread,
|
unread = it.unread - thread,
|
||||||
@@ -1760,7 +1766,17 @@ class MessengerViewModel(
|
|||||||
onSuccess = {
|
onSuccess = {
|
||||||
filesUpdate { it.copy(busy = null) }
|
filesUpdate { it.copy(busy = null) }
|
||||||
update {
|
update {
|
||||||
it.copy(openChat = it.openChat?.copy(lines = live.history(chat.threadId)))
|
it.copy(
|
||||||
|
openChat =
|
||||||
|
it.openChat?.copy(
|
||||||
|
lines = live.history(chat.threadId),
|
||||||
|
// The peer's echo is what actually turns it on,
|
||||||
|
// and it lands here rather than where it was
|
||||||
|
// asked for.
|
||||||
|
offTheRecord =
|
||||||
|
chat.peerId?.let { id -> live.offTheRecord(id) } ?: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
@@ -2044,6 +2060,29 @@ class MessengerViewModel(
|
|||||||
|
|
||||||
// -- Disappearing messages (§6.11) ---------------------------------------.
|
// -- Disappearing messages (§6.11) ---------------------------------------.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the other side to hold this conversation off the record, or to stop.
|
||||||
|
*
|
||||||
|
* Nothing changes here on the strength of the ask: it takes effect when
|
||||||
|
* they echo it, so the setting is re-read rather than assumed.
|
||||||
|
*/
|
||||||
|
fun setOffTheRecord(on: Boolean) =
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
val chat = signedIn()?.openChat ?: return@launch
|
||||||
|
val peerId = chat.peerId ?: return@launch
|
||||||
|
val done =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { current.proposeOffTheRecord(peerId, on) }
|
||||||
|
}
|
||||||
|
update { state ->
|
||||||
|
state.copy(
|
||||||
|
openChat = state.openChat?.copy(offTheRecord = current.offTheRecord(peerId)),
|
||||||
|
error = done.exceptionOrNull()?.let(::describe),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the timer for the open conversation, on both sides.
|
* Set the timer for the open conversation, on both sides.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -85,4 +85,37 @@ class OffTheRecordTest {
|
|||||||
assertTrue(branch.contains("vault.saveOffTheRecord"))
|
assertTrue(branch.contains("vault.saveOffTheRecord"))
|
||||||
assertTrue(branch.contains("!Content.offTheRecordIsEcho(content)"), "an echo must not be echoed")
|
assertTrue(branch.contains("!Content.offTheRecordIsEcho(content)"), "an echo must not be echoed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the chat offers the switch and says when it is on`() {
|
||||||
|
val chat =
|
||||||
|
read(
|
||||||
|
"src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt",
|
||||||
|
"app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt",
|
||||||
|
)
|
||||||
|
assertTrue(chat.contains("otr-toggle"), "no way to turn it on")
|
||||||
|
assertTrue(chat.contains("off the record, nothing is written down"), "the chat never says so")
|
||||||
|
|
||||||
|
val vm =
|
||||||
|
read(
|
||||||
|
"src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
|
||||||
|
"app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
|
||||||
|
)
|
||||||
|
assertTrue(vm.contains("fun setOffTheRecord("))
|
||||||
|
assertTrue(vm.contains("proposeOffTheRecord(peerId, on)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The echo arrives during a poll, not while the menu is open, so the state
|
||||||
|
* has to be re-read there or the banner lies until the chat is reopened.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the echo turning it on reaches the open conversation`() {
|
||||||
|
val vm =
|
||||||
|
read(
|
||||||
|
"src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
|
||||||
|
"app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
|
||||||
|
)
|
||||||
|
assertTrue(vm.contains("live.offTheRecord(id)"), "polling never re-reads the setting")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user