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 7674faa..36eedda 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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()) { 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 ee4f923..1bde7f7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -217,6 +217,11 @@ class MessengerViewModel( val replyReminderAt: Long = 0, /** Messages queued to go out later: when, and what. Soonest first. */ val scheduled: List> = 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. */ diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt index e41861f..b7a12f3 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OffTheRecordTest.kt @@ -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") + } }