Merge branch 'feat/off-the-record-ui' into 'main'

Off-the-record conversations: the control

Closes #27

See merge request paulhitt/flamenet-messenger-android!130
This commit was merged in pull request #157.
This commit is contained in:
Paul Hitt
2026-09-12 01:45:03 +00:00
3 changed files with 85 additions and 1 deletions
@@ -3609,6 +3609,17 @@ private fun ConversationMenu(
expanded = open,
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")
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
MenuChoice(
@@ -3706,6 +3717,7 @@ private fun ColumnScope.ConversationStatusLine(
DISAPPEAR_LABELS[chat.disappearSeconds]?.let {
add("disappearing after $it, on both phones")
}
if (chat.offTheRecord) add("off the record, nothing is written down")
if (snoozed) add("snoozed")
if (chat.replyReminderAt != 0L) add("reminder set")
if (chat.scheduled.isNotEmpty()) {
@@ -217,6 +217,11 @@ class MessengerViewModel(
val replyReminderAt: Long = 0,
/** Messages queued to go out later: when, and what. Soonest first. */
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),
scheduled = current.scheduledFor(peerId),
pinnedMessageId = current.pinnedMessage(thread),
offTheRecord = current.offTheRecord(peerId),
),
// Opening the thread is what marks it read.
unread = it.unread - thread,
@@ -1760,7 +1766,17 @@ class MessengerViewModel(
onSuccess = {
filesUpdate { it.copy(busy = null) }
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 ->
@@ -2044,6 +2060,29 @@ class MessengerViewModel(
// -- 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.
*/
@@ -85,4 +85,37 @@ class OffTheRecordTest {
assertTrue(branch.contains("vault.saveOffTheRecord"))
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")
}
}