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. * notification and this monitor isn't consulted which is the desired behaviour.
*/ */
@Singleton @Singleton
class ActiveThreadMonitor @Inject constructor() { class ActiveThreadMonitor @Inject constructor(
private val messageBubbleController: MessageBubbleController
) {
@Volatile @Volatile
var activeQuestionId: String? = null var activeQuestionId: String? = null
private set private set
fun enter(questionId: String) { 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) { fun leave(questionId: String) {

View File

@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() {
fun dismiss() { fun dismiss() {
_bubble.value = null _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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -80,10 +80,33 @@ fun MessageBubbleOverlay(
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
// Auto-dismiss if the user neither opens nor moves it for a while. // The bubble persists until the message is read — opening the conversation clears it (via
LaunchedEffect(current.questionId, current.count) { // ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
delay(12_000) var dragging by remember(current.questionId) { mutableStateOf(false) }
viewModel.dismiss() 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( Box(
@ -96,15 +119,27 @@ fun MessageBubbleOverlay(
.border(2.5.dp, Color.White, CircleShape) .border(2.5.dp, Color.White, CircleShape)
.pointerInput(current.questionId) { .pointerInput(current.questionId) {
detectDragGestures( detectDragGestures(
onDragStart = { dragging = true },
onDrag = { change, drag -> onDrag = { change, drag ->
change.consume() change.consume()
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge) offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx) offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx)
}, },
onDragEnd = { onDragEnd = {
dragging = false
if (nearDismiss) {
viewModel.dismiss()
} else {
// Snap to whichever side is closer (chat-head behaviour). // Snap to whichever side is closer (chat-head behaviour).
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
} }
nearDismiss = false
},
onDragCancel = {
dragging = false
nearDismiss = false
}
) )
} }
.pointerInput(current.questionId) { .pointerInput(current.questionId) {