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)
|
WsEvent.SessionUpdated(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"reaction" -> {
|
||||||
|
val kind = payload?.get("kind") as? String ?: ""
|
||||||
|
WsEvent.ReactionEvent(kind)
|
||||||
|
}
|
||||||
|
|
||||||
"approval.request" -> {
|
"approval.request" -> {
|
||||||
val command = payload?.get("command") as? String
|
val command = payload?.get("command") as? String
|
||||||
val description = payload?.get("description") as? String
|
val description = payload?.get("description") as? String
|
||||||
|
|||||||
@@ -152,6 +152,17 @@ sealed class WsEvent {
|
|||||||
val data: Map<String, Any?>?,
|
val data: Map<String, Any?>?,
|
||||||
) : WsEvent()
|
) : 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 ─────────────────────────────────────────────────────────
|
// ── Fallback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class Unknown(
|
data class Unknown(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -86,6 +87,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -411,6 +414,14 @@ fun ChatScreen(
|
|||||||
streamingMessage = streamingState.streamingMessage,
|
streamingMessage = streamingState.streamingMessage,
|
||||||
isThinking = streamingState.isThinking,
|
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(
|
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(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
// Attachment state
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
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]. */
|
/** Convenience — derived from [connectionStatus]. */
|
||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||||
@@ -108,7 +112,7 @@ data class SecretPromptUi(
|
|||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val startCleanup: Boolean,
|
private val startCleanup: Boolean,
|
||||||
private val repo: ChatPersistenceRepository =
|
repo: ChatPersistenceRepository =
|
||||||
ChatPersistenceRepository(
|
ChatPersistenceRepository(
|
||||||
HermesDatabase.get(application).chatMessageDao(),
|
HermesDatabase.get(application).chatMessageDao(),
|
||||||
),
|
),
|
||||||
@@ -121,8 +125,13 @@ class ChatViewModel(
|
|||||||
private val _streamingState = MutableStateFlow(StreamingState())
|
private val _streamingState = MutableStateFlow(StreamingState())
|
||||||
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
||||||
|
|
||||||
|
/** Tracks the auto-clear coroutine for reaction animations. */
|
||||||
|
private var reactionClearJob: Job? = null
|
||||||
|
|
||||||
private val wsClient = HermesWsClient
|
private val wsClient = HermesWsClient
|
||||||
|
|
||||||
|
// ── Session persistence ──────────────────────────────────────────────
|
||||||
|
private val repo: ChatPersistenceRepository = repo
|
||||||
private val slashDispatcher = SlashCommandDispatcher()
|
private val slashDispatcher = SlashCommandDispatcher()
|
||||||
private val searchDelegate =
|
private val searchDelegate =
|
||||||
ChatSearchDelegate(
|
ChatSearchDelegate(
|
||||||
@@ -379,6 +388,23 @@ class ChatViewModel(
|
|||||||
// it via a LaunchedEffect and triggers the snackbar.
|
// 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 */ }
|
else -> { /* reducer handles these */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
is WsEvent.SecretRequest -> 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 ──────────────────────────────────────────────────
|
// ── GatewayReady ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user