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:
co-authored by
Claude Opus 5
parent
1656c87491
commit
f147bbf6ae
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user