Reactions during a call #154

Merged
paulhitt merged 1 commits from feat/call-reactions into main 2026-09-12 01:11:13 +00:00
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"))
}
}
@@ -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() },
),
)
}
@@ -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)
}
}