Reactions during a call #154
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user