feat(#553): animate reaction WS event (ily / <3 / good bot → hearts)
This commit is contained in:
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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user