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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 21:07:31 -04:00
co-authored by Claude Opus 5
parent 1656c87491
commit f147bbf6ae
6 changed files with 228 additions and 1 deletions
@@ -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 }
@@ -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<CallAction>,
/** 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<String?>(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 " +
@@ -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)) }
@@ -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"))
}
}