From 608ddcfc5b942c1abfea087a969039a34d2c08e5 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 15:20:38 -0500 Subject: [PATCH] feat(bubble): drag-to-dismiss zone, no auto-timeout, dismiss on conversation enter - MessageBubbleOverlay: drag-to-dismiss target at bottom-center, no 12s auto-timeout (persists until read) - MessageBubbleController: dismissFor clears bubble when its conversation is opened - ActiveThreadMonitor: calls dismissFor on enter, clearing the bubble for that thread --- .../notifications/ActiveThreadMonitor.kt | 10 +++- .../notifications/MessageBubbleController.kt | 5 ++ .../ui/components/MessageBubbleOverlay.kt | 51 ++++++++++++++++--- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt b/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt index 8460660f..86344cd1 100644 --- a/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt +++ b/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt @@ -12,13 +12,19 @@ import javax.inject.Singleton * notification and this monitor isn't consulted — which is the desired behaviour. */ @Singleton -class ActiveThreadMonitor @Inject constructor() { +class ActiveThreadMonitor @Inject constructor( + private val messageBubbleController: MessageBubbleController +) { @Volatile var activeQuestionId: String? = null private set fun enter(questionId: String) { - if (questionId.isNotBlank()) activeQuestionId = questionId + if (questionId.isNotBlank()) { + activeQuestionId = questionId + // Opening the conversation counts as reading it — clear any chat bubble for it. + messageBubbleController.dismissFor(questionId) + } } fun leave(questionId: String) { diff --git a/app/src/main/java/app/closer/notifications/MessageBubbleController.kt b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt index 61289503..c0c15005 100644 --- a/app/src/main/java/app/closer/notifications/MessageBubbleController.kt +++ b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt @@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() { fun dismiss() { _bubble.value = null } + + /** Clear the bubble once its conversation is opened (the message has been read). */ + fun dismissFor(questionId: String) { + _bubble.update { current -> if (current?.questionId == questionId) null else current } + } } diff --git a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt index 410de992..cf86c5c4 100644 --- a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt +++ b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt @@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Close 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController import app.closer.ui.theme.CloserPalette import coil.compose.AsyncImage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import javax.inject.Inject import kotlin.math.roundToInt @@ -80,10 +80,33 @@ fun MessageBubbleOverlay( var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } - // Auto-dismiss if the user neither opens nor moves it for a while. - LaunchedEffect(current.questionId, current.count) { - delay(12_000) - viewModel.dismiss() + // The bubble persists until the message is read — opening the conversation clears it (via + // ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout. + var dragging by remember(current.questionId) { mutableStateOf(false) } + var nearDismiss by remember(current.questionId) { mutableStateOf(false) } + val dismissZonePx = with(density) { 150.dp.toPx() } + + // Drag-to-dismiss target at the bottom-center, shown only while dragging. + if (dragging) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp) + .size(if (nearDismiss) 66.dp else 54.dp) + .shadow(6.dp, CircleShape) + .clip(CircleShape) + .background( + if (nearDismiss) CloserPalette.PinkAccentDeep else Color.Black.copy(alpha = 0.45f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Dismiss", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } } Box( @@ -96,14 +119,26 @@ fun MessageBubbleOverlay( .border(2.5.dp, Color.White, CircleShape) .pointerInput(current.questionId) { detectDragGestures( + onDragStart = { dragging = true }, onDrag = { change, drag -> change.consume() offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge) offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx) + nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx) }, onDragEnd = { - // Snap to whichever side is closer (chat-head behaviour). - offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge + dragging = false + if (nearDismiss) { + viewModel.dismiss() + } else { + // Snap to whichever side is closer (chat-head behaviour). + offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge + } + nearDismiss = false + }, + onDragCancel = { + dragging = false + nearDismiss = false } ) }