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) + } +}