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
This commit is contained in:
null 2026-06-24 15:20:38 -05:00
parent adb61715fe
commit 608ddcfc5b
3 changed files with 56 additions and 10 deletions

View File

@ -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) {

View File

@ -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 }
}
}

View File

@ -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
}
)
}