diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index c9cc5b0d..04cc135c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 01335f4a..698ff2dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -152,6 +152,17 @@ sealed class WsEvent { val data: Map?, ) : WsEvent() + // ── Reaction event ──────────────────────────────────────────────────── + + /** + * Backend emitted an affection reaction (ily / <3 / good bot). + * Payload: `{ "kind": "" }`. Purely cosmetic — play a hearts + * animation in the chat UI; no persistence needed. + */ + data class ReactionEvent( + val kind: String = "", + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 953a2fd9..09af7055 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 75165d4c..adb77976 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -76,6 +76,10 @@ data class ChatUiState( val commandCatalog: CommandCatalog = CommandCatalog(), // Attachment state val pendingAttachments: List = 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.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 */ } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 2bb65f78..671e1f1e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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 ──────────────────────────────────────────────────