feat(#553): animate reaction WS event (ily / <3 / good bot → hearts)

This commit is contained in:
M57 authored and GitHub committed 2026-07-12 05:11:17 +03:00
1 parent cc917afcbd
commit 306861a222
5 files changed
+143 -1

No files matched your search

@@ -126,6 +126,11 @@ object EventParser {
WsEvent.SessionUpdated(payload)
}
"reaction" -> {
val kind = payload?.get("kind") as? String ?: ""
WsEvent.ReactionEvent(kind)
}
"approval.request" -> {
val command = payload?.get("command") as? String
val description = payload?.get("description") as? String
@@ -152,6 +152,17 @@ sealed class WsEvent {
val data: Map<String, Any?>?,
) : WsEvent()
// ── Reaction event ────────────────────────────────────────────────────
/**
* Backend emitted an affection reaction (ily / <3 / good bot).
* Payload: `{ "kind": "<str>" }`. Purely cosmetic — play a hearts
* animation in the chat UI; no persistence needed.
*/
data class ReactionEvent(
val kind: String = "",
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -86,6 +87,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -108,6 +110,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
@@ -411,6 +414,14 @@ fun ChatScreen(
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
)
// Reaction hearts animation (purely cosmetic — fades out
// automatically after the ViewModel clears the state)
key(state.reactionTriggerId) {
ReactionHeartsOverlay(
reactionKind = state.reactionKind,
)
}
}
ChatInputBar(
@@ -1955,3 +1966,89 @@ private fun ReloginDialog(
},
)
}
// ── Reaction hearts animation ──────────────────────────────────────────
/**
* Lightweight floating-hearts animation triggered by a `reaction` WS event.
* Hearts rise from the bottom-center of the chat area, drift slightly
* horizontally, and fade out — purely cosmetic, no persistence.
*/
@Composable
private fun ReactionHeartsOverlay(
reactionKind: String?,
modifier: Modifier = Modifier,
) {
val emojis =
remember(reactionKind) {
when (reactionKind) {
"ily", "<3", "good bot" ->
listOf("💗", "❤️", "💕", "💖", "🩷", "💘", "💝")
else -> listOf("💗", "❤️", "💕", "💖")
}
}
androidx.compose.animation.AnimatedVisibility(
visible = reactionKind != null,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(400)),
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
emojis.forEachIndexed { index, emoji ->
key("heart_$index") {
FloatingHeart(
emoji = emoji,
delayMs = index * 120L + 100L,
horizontalOffset = ((index - emojis.size / 2) * 28).dp,
)
}
}
}
}
}
@Composable
private fun FloatingHeart(
emoji: String,
delayMs: Long,
horizontalOffset: Dp,
) {
val alpha = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
LaunchedEffect(Unit) {
delay(delayMs)
// Phase 1: fade in quickly at the bottom
launch {
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
// Phase 2: fade out slowly while rising
alpha.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 1400, easing = LinearEasing),
)
}
// Float upward over the full animation
launch {
offsetY.animateTo(
targetValue = -220f,
animationSpec = tween(durationMillis = 1600, easing = LinearEasing),
)
}
}
Text(
text = emoji,
fontSize = 24.sp,
modifier =
Modifier
.offset(x = horizontalOffset, y = offsetY.value.dp)
.graphicsLayer(alpha = alpha.value),
)
}
@@ -76,6 +76,10 @@ data class ChatUiState(
val commandCatalog: CommandCatalog = CommandCatalog(),
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears
val reactionKind: String? = null,
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
val reactionTriggerId: Long = 0L,
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -108,7 +112,7 @@ data class SecretPromptUi(
class ChatViewModel(
application: Application,
private val startCleanup: Boolean,
private val repo: ChatPersistenceRepository =
repo: ChatPersistenceRepository =
ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(),
),
@@ -121,8 +125,13 @@ class ChatViewModel(
private val _streamingState = MutableStateFlow(StreamingState())
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
/** Tracks the auto-clear coroutine for reaction animations. */
private var reactionClearJob: Job? = null
private val wsClient = HermesWsClient
// ── Session persistence ──────────────────────────────────────────────
private val repo: ChatPersistenceRepository = repo
private val slashDispatcher = SlashCommandDispatcher()
private val searchDelegate =
ChatSearchDelegate(
@@ -379,6 +388,23 @@ class ChatViewModel(
// it via a LaunchedEffect and triggers the snackbar.
}
is WsEvent.ReactionEvent -> {
// Cancel any previous auto-clear to avoid race (agy finding #1)
reactionClearJob?.cancel()
_uiState.update {
it.copy(
reactionKind = event.kind,
reactionTriggerId = it.reactionTriggerId + 1L,
)
}
// Auto-clear after the animation duration
reactionClearJob =
viewModelScope.launch {
delay(2_000L)
_uiState.update { it.copy(reactionKind = null) }
}
}
else -> { /* reducer handles these */ }
}
}
@@ -94,6 +94,9 @@ object ChatWsEventReducer {
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
}
// ── GatewayReady ──────────────────────────────────────────────────