From f147bbf6aead49c95770086d2dd34b5bdef119f4 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 11 Sep 2026 21:07:31 -0400 Subject: [PATCH] Reactions during a call Tap a reaction while somebody is talking and it shows on their screen for a moment. Agreement without interrupting, on a voice call as well as video. The signal rides the ratchet like every other call signal, is accepted only for the call in progress and only once it is up, and is recorded nowhere: when the call ends there is no trace of it. An older build ignores the action it does not know. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/CallManager.kt | 32 ++++++++++ .../io/flamenet/messenger/app/CallScreen.kt | 58 +++++++++++++++++ .../messenger/app/MessengerViewModel.kt | 20 ++++++ .../messenger/app/CallReactionSendTest.kt | 62 +++++++++++++++++++ .../main/kotlin/io/flamenet/e2e/Content.kt | 10 ++- .../io/flamenet/e2e/CallReactionTest.kt | 47 ++++++++++++++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/CallReactionSendTest.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/CallReactionTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index 6aa332e..47318fe 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -530,6 +530,16 @@ class CallManager( ) } + Content.CallSignal.REACTION -> { + // For the call in progress and no other, and never filed + // anywhere: once the call ends there is no trace of it. + if (incoming.callId != current?.callId) return + if (state != State.CONNECTING && state != State.ACTIVE) return + incoming.emoji?.takeIf { it.isNotBlank() }?.let { emoji -> + main.post { runCatching { onReaction?.invoke(emoji) } } + } + } + Content.CallSignal.END -> { // An end for some other call must not hang up this one. This // branch used to hang up on any end at all, for any call id. @@ -564,6 +574,28 @@ class CallManager( /** An offer arrived too old to ring. The view model records the missed call. */ var onStaleOffer: ((Long, Long) -> Unit)? = null + /** + * A reaction arrived during the call. Fired on the main thread, and the + * screen is expected to show it briefly and forget it. + */ + var onReaction: ((String) -> Unit)? = null + + /** + * Send one reaction to whoever is on the call. Dropped when there is no + * call to send it into: a reaction is for a conversation in progress. + */ + fun sendReaction(emoji: String) { + val active = current ?: return + if (state != State.CONNECTING && state != State.ACTIVE) return + if (emoji.isBlank()) return + runCatching { + signal( + active.peerId, + Content.CallSignal(Content.CallSignal.REACTION, active.callId, emoji = emoji), + ) + } + } + /** This account's user id, read when a signal arrives. */ var ownUserId: () -> Long? = { null } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index f781722..22d7ee7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -17,7 +17,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay /** * A call, as a screen. @@ -40,6 +46,10 @@ fun CallScreenBody( actions: List, /** Shown under the status when this relay has no TURN server. */ warning: String? = null, + /** Set while a call is connected: tapping one sends it to the other side. */ + onReact: ((String) -> Unit)? = null, + /** A reaction to show for a moment, from either side. */ + floating: String? = null, ) { val initials = peerName @@ -103,6 +113,34 @@ fun CallScreenBody( Spacer(Modifier.weight(1f)) + // A tap says something without interrupting whoever is talking. It + // shows for a moment on both screens and is written down nowhere. + floating?.let { emoji -> + Text( + emoji, + fontSize = 44.sp, + modifier = Modifier.testTag("call-reaction-floating"), + ) + Spacer(Modifier.height(Space.md)) + } + onReact?.let { react -> + Row(horizontalArrangement = Arrangement.spacedBy(Space.lg)) { + CALL_REACTIONS.forEach { emoji -> + Text( + emoji, + fontSize = 26.sp, + modifier = + Modifier + .clip(CircleShape) + .clickable { react(emoji) } + .padding(6.dp) + .testTag("call-react"), + ) + } + } + Spacer(Modifier.height(Space.lg)) + } + // The kit puts the secondaries on an arc around a 72dp primary. val primary = actions.firstOrNull { it.isPrimary } val others = actions.filterNot { it.isPrimary } @@ -118,6 +156,9 @@ fun CallScreenBody( } } +/** The same set the chat offers, so there is one vocabulary to learn. */ +private val CALL_REACTIONS = listOf("❤️", "😂", "😮", "😢", "🔥") + /** One control on the call screen. */ data class CallAction( val id: String, @@ -265,10 +306,27 @@ fun LiveCallScreen( actions = actions, ) } else { + // Cleared on its own: a reaction is a moment, not a state to manage. + var floating by remember { mutableStateOf(null) } + LaunchedEffect(call.reactionTick) { + val arrived = call.reaction + if (call.reactionTick > 0 && arrived != null) { + floating = arrived + delay(2_000) + floating = null + } + } CallScreenBody( peerName = call.peerName, status = status, actions = actions, + onReact = + if (call.state == CallManager.State.ACTIVE) { + { emoji -> model.sendCallReaction(emoji) } + } else { + null + }, + floating = floating, warning = if (call.relayless && call.state != CallManager.State.ACTIVE) { "This server has no call relay, so the call only connects if a " + 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 6c5ec3c..1eaf1ed 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -172,6 +172,10 @@ class MessengerViewModel( val videoTick: Int = 0, val incoming: Boolean, val muted: Boolean = false, + /** The last reaction to arrive, shown for a moment and forgotten. */ + val reaction: String? = null, + /** Bumped per arrival, so the same emoji twice still shows twice. */ + val reactionTick: Int = 0, /** * True when this relay has no TURN server, so a call that cannot * find a direct path will simply fail. @@ -2433,6 +2437,17 @@ class MessengerViewModel( } } } + manager.onReaction = { emoji -> + update { phase -> + phase.copy( + call = + phase.call?.copy( + reaction = emoji, + reactionTick = phase.call.reactionTick + 1, + ), + ) + } + } manager.onStaleOffer = { fromUser, _ -> // Too old to ring, the caller gave up long ago, but not too old to // matter: file it so the Calls tab shows who tried. @@ -2617,6 +2632,11 @@ class MessengerViewModel( */ fun activeCallManager(): CallManager? = calls + /** Send one reaction into the call in progress. */ + fun sendCallReaction(emoji: String) { + calls?.sendReaction(emoji) + } + fun setCallMuted(muted: Boolean) { calls?.setMuted(muted) update { it.copy(call = it.call?.copy(muted = muted)) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/CallReactionSendTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/CallReactionSendTest.kt new file mode 100644 index 0000000..3f3fb45 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/CallReactionSendTest.kt @@ -0,0 +1,62 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Reactions during a call are for the call in progress, and they leave nothing + * behind. `CallManager` needs WebRTC, so this reads the source the way + * `SecondDeviceCallTest` does. + */ +class CallReactionSendTest { + private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText() + + private val source: String by lazy { + read( + "src/main/kotlin/io/flamenet/messenger/app/CallManager.kt", + "app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt", + ) + } + + private fun body(marker: String): String { + val start = source.indexOf(marker) + assertTrue(start >= 0, "no $marker in CallManager.kt") + val rest = source.substring(start + marker.length) + val end = rest.indexOf("\n /**").let { if (it < 0) rest.length else it } + return rest.substring(0, end) + } + + @Test + fun `a reaction is only sent into a call that is up`() { + val send = body("fun sendReaction(") + assertTrue(send.contains("state != State.CONNECTING && state != State.ACTIVE")) + assertTrue(send.contains("Content.CallSignal.REACTION")) + } + + @Test + fun `an arriving reaction is for the current call only, and is never filed`() { + val start = source.indexOf("Content.CallSignal.REACTION ->") + assertTrue(start >= 0, "no reaction branch in onSignal") + val branch = source.substring(start, minOf(source.length, start + 900)) + assertTrue(branch.contains("incoming.callId != current?.callId")) + assertTrue(branch.contains("state != State.CONNECTING && state != State.ACTIVE")) + assertTrue(branch.contains("onReaction?.invoke")) + assertTrue( + !branch.contains("recordCall"), + "a reaction during a call must leave no record", + ) + } + + @Test + fun `the call screen can send one and shows what arrives`() { + val screen = + read( + "src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt", + "app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt", + ) + assertTrue(screen.contains("call-react"), "no way to send a reaction") + assertTrue(screen.contains("call-reaction-floating"), "nothing draws what arrives") + assertTrue(screen.contains("model.sendCallReaction")) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index fa51cbb..5b0a75d 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -135,12 +135,18 @@ data class Content( * Offer/answer: this call carries video. */ val video: Boolean = false, + /** + * One emoji, sent during a connected call and shown for a moment on the + * other screen. Never recorded: nothing of it survives the call. + */ + val emoji: String? = null, ) { companion object { const val OFFER = "call-offer" const val ANSWER = "call-answer" const val END = "call-end" - val ACTIONS = setOf(OFFER, ANSWER, END) + const val REACTION = "call-reaction" + val ACTIONS = setOf(OFFER, ANSWER, END, REACTION) } } @@ -505,6 +511,7 @@ data class Content( val call = JSONObject().put("id", signal.callId) signal.sdp?.let { call.put("sdp", it) } signal.reason?.let { call.put("reason", it) } + signal.emoji?.let { call.put("emoji", it) } if (signal.video) call.put("video", true) return """{"fnc":1,"kind":${quote(signal.action)},"call":$call}""" } @@ -745,6 +752,7 @@ data class Content( sdp = call.optString("sdp").takeIf { it.isNotEmpty() }, reason = call.optString("reason").takeIf { it.isNotEmpty() }, video = call.optBoolean("video", false), + emoji = call.optString("emoji").takeIf { it.isNotEmpty() }, ), ) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/CallReactionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/CallReactionTest.kt new file mode 100644 index 0000000..a8e10d3 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/CallReactionTest.kt @@ -0,0 +1,47 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A reaction tapped during a call: one emoji, shown for a moment, recorded + * nowhere. + */ +class CallReactionTest { + @Test + fun `a reaction is a call signal, and carries its emoji`() { + val wire = + Content.callSignal( + Content.CallSignal(Content.CallSignal.REACTION, "c-1", emoji = "+1"), + ) + val read = Content.parse(wire).call + assertEquals(Content.CallSignal.REACTION, read?.action) + assertEquals("c-1", read?.callId) + assertEquals("+1", read?.emoji) + } + + @Test + fun `an offer carries no emoji key at all`() { + val wire = Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c-1", sdp = "v=0")) + assertFalse(wire.contains("emoji")) + assertNull(Content.parse(wire).call?.emoji) + } + + /** Control traffic: it must never turn into a message in a thread. */ + @Test + fun `a call reaction is not renderable`() { + val wire = + Content.callSignal( + Content.CallSignal(Content.CallSignal.REACTION, "c-1", emoji = "+1"), + ) + assertFalse(Content.parse(wire).isRenderable) + } + + @Test + fun `the action is one the engine recognises`() { + assertTrue(Content.CallSignal.REACTION in Content.CallSignal.ACTIONS) + } +} -- 2.54.0