From e4cdbac7b1ca9dc1e4ea7cf353f455de3bc30948 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 16:14:07 -0500 Subject: [PATCH] =?UTF-8?q?refactor(notifications):=20questionId=20?= =?UTF-8?q?=E2=86=92=20conversationId=20across=20bubble,=20deep=20link=20r?= =?UTF-8?q?outing,=20FCM=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActiveThreadMonitor: activeQuestionId → activeConversationId, enter/leave use conversationId - MessageBubbleController: IncomingMessageBubble uses conversationId, show/dismissFor updated - PartnerNotificationManager: CHAT_MESSAGE routes to conversation, payload includes conversationId - MessageBubbleOverlay: onOpen passes conversationId, pointerInput keys on conversationId - AppMessagingService: chat_message type reads conversation_id, bubble show uses conversationId - MainActivity: deepLinkRouteFromIntent reads conversation_id from FCM extras --- app/src/main/java/app/closer/MainActivity.kt | 1 + .../core/notifications/AppMessagingService.kt | 7 ++++--- .../closer/notifications/ActiveThreadMonitor.kt | 14 +++++++------- .../notifications/MessageBubbleController.kt | 16 ++++++++-------- .../notifications/PartnerNotificationManager.kt | 15 ++++++++------- .../closer/ui/components/MessageBubbleOverlay.kt | 16 ++++++++-------- 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 6d96f5a7..cd07813a 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -127,6 +127,7 @@ class MainActivity : AppCompatActivity() { val coupleId = intent.getStringExtra("couple_id") ?: "" val payload = PartnerNotificationPayload( questionId = intent.getStringExtra("question_id"), + conversationId = intent.getStringExtra("conversation_id"), gameSessionId = intent.getStringExtra("game_session_id"), capsuleId = intent.getStringExtra("capsule_id"), challengeId = intent.getStringExtra("challenge_id"), diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index bbf4176b..f459ec75 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -62,9 +62,9 @@ class AppMessagingService : FirebaseMessagingService() { // shown by the OS from the FCM notification block). For a chat message, surface the // draggable in-app bubble — unless the user is already reading that exact thread. if (type == "chat_message") { - val questionId = message.data["question_id"] - if (questionId != null && questionId != activeThreadMonitor.activeQuestionId) { - messageBubbleController.show(coupleId, questionId, message.data["sender_avatar_url"]) + val conversationId = message.data["conversation_id"] + if (conversationId != null && conversationId != activeThreadMonitor.activeConversationId) { + messageBubbleController.show(coupleId, conversationId, message.data["sender_avatar_url"]) } return } @@ -76,6 +76,7 @@ class AppMessagingService : FirebaseMessagingService() { coupleId = coupleId, payload = PartnerNotificationPayload( questionId = message.data["question_id"], + conversationId = message.data["conversation_id"], gameSessionId = message.data["game_session_id"], capsuleId = message.data["capsule_id"], challengeId = message.data["challenge_id"], diff --git a/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt b/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt index 86344cd1..02750ab3 100644 --- a/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt +++ b/app/src/main/java/app/closer/notifications/ActiveThreadMonitor.kt @@ -16,18 +16,18 @@ class ActiveThreadMonitor @Inject constructor( private val messageBubbleController: MessageBubbleController ) { @Volatile - var activeQuestionId: String? = null + var activeConversationId: String? = null private set - fun enter(questionId: String) { - if (questionId.isNotBlank()) { - activeQuestionId = questionId + fun enter(conversationId: String) { + if (conversationId.isNotBlank()) { + activeConversationId = conversationId // Opening the conversation counts as reading it — clear any chat bubble for it. - messageBubbleController.dismissFor(questionId) + messageBubbleController.dismissFor(conversationId) } } - fun leave(questionId: String) { - if (activeQuestionId == questionId) activeQuestionId = null + fun leave(conversationId: String) { + if (activeConversationId == conversationId) activeConversationId = null } } diff --git a/app/src/main/java/app/closer/notifications/MessageBubbleController.kt b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt index c0c15005..7cd26822 100644 --- a/app/src/main/java/app/closer/notifications/MessageBubbleController.kt +++ b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt @@ -10,7 +10,7 @@ import javax.inject.Singleton /** State for the floating in-app message bubble (a chat-head shown over the app's own UI). */ data class IncomingMessageBubble( val coupleId: String, - val questionId: String, + val conversationId: String, val count: Int = 1, val avatarUrl: String? = null ) @@ -25,14 +25,14 @@ class MessageBubbleController @Inject constructor() { private val _bubble = MutableStateFlow(null) val bubble: StateFlow = _bubble.asStateFlow() - /** Show the bubble for [questionId], or bump its unread count if it's already showing. */ - fun show(coupleId: String, questionId: String, avatarUrl: String?) { - if (coupleId.isBlank() || questionId.isBlank()) return + /** Show the bubble for [conversationId], or bump its unread count if it's already showing. */ + fun show(coupleId: String, conversationId: String, avatarUrl: String?) { + if (coupleId.isBlank() || conversationId.isBlank()) return _bubble.update { current -> - if (current != null && current.questionId == questionId) { + if (current != null && current.conversationId == conversationId) { current.copy(count = current.count + 1, avatarUrl = avatarUrl ?: current.avatarUrl) } else { - IncomingMessageBubble(coupleId, questionId, 1, avatarUrl) + IncomingMessageBubble(coupleId, conversationId, 1, avatarUrl) } } } @@ -42,7 +42,7 @@ class MessageBubbleController @Inject constructor() { } /** 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 } + fun dismissFor(conversationId: String) { + _bubble.update { current -> if (current?.conversationId == conversationId) null else current } } } diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 6dfd5043..cb6d6161 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -54,10 +54,10 @@ class PartnerNotificationManager @Inject constructor( val settings = settingsRepository.settings.first() - // Don't ping for a chat message in the thread the user is already reading live. + // Don't ping for a chat message in the conversation the user is already reading live. if (type == PartnerNotificationType.CHAT_MESSAGE && - payload.questionId != null && - payload.questionId == activeThreadMonitor.activeQuestionId + payload.conversationId != null && + payload.conversationId == activeThreadMonitor.activeConversationId ) return if (!isEnabled(type, settings)) return @@ -265,11 +265,11 @@ enum class PartnerNotificationType( CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE GENTLE_REMINDER -> AppRoute.DAILY_QUESTION DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION - // Open the actual discussion thread so the partner can reply in place. - CHAT_MESSAGE -> if (payload.questionId != null && coupleId.isNotBlank()) { - AppRoute.questionThread(coupleId, payload.questionId) + // Open the actual conversation so the partner can reply in place. + CHAT_MESSAGE -> if (payload.conversationId != null && coupleId.isNotBlank()) { + AppRoute.conversation(coupleId, payload.conversationId) } else { - AppRoute.ANSWER_HISTORY + AppRoute.MESSAGES } OUTCOME_REMINDER -> AppRoute.SETTINGS PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME @@ -308,6 +308,7 @@ enum class PartnerNotificationType( */ data class PartnerNotificationPayload( val questionId: String? = null, + val conversationId: String? = null, val gameSessionId: String? = null, val capsuleId: String? = null, val challengeId: String? = null, 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 cf86c5c4..dae0c143 100644 --- a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt +++ b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt @@ -59,7 +59,7 @@ class MessageBubbleViewModel @Inject constructor( */ @Composable fun MessageBubbleOverlay( - onOpen: (coupleId: String, questionId: String) -> Unit, + onOpen: (coupleId: String, conversationId: String) -> Unit, viewModel: MessageBubbleViewModel = hiltViewModel() ) { val bubble by viewModel.bubble.collectAsState() @@ -77,13 +77,13 @@ fun MessageBubbleOverlay( val rightEdge = (maxXpx - sizePx - marginPx).coerceAtLeast(0f) // Reset position to the right edge each time a new bubble appears. - var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } - var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } + var offsetX by remember(current.conversationId) { mutableFloatStateOf(rightEdge) } + var offsetY by remember(current.conversationId) { mutableFloatStateOf(maxYpx * 0.32f) } // 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) } + var dragging by remember(current.conversationId) { mutableStateOf(false) } + var nearDismiss by remember(current.conversationId) { mutableStateOf(false) } val dismissZonePx = with(density) { 150.dp.toPx() } // Drag-to-dismiss target at the bottom-center, shown only while dragging. @@ -117,7 +117,7 @@ fun MessageBubbleOverlay( .clip(CircleShape) .background(CloserPalette.PurpleRich) .border(2.5.dp, Color.White, CircleShape) - .pointerInput(current.questionId) { + .pointerInput(current.conversationId) { detectDragGestures( onDragStart = { dragging = true }, onDrag = { change, drag -> @@ -142,9 +142,9 @@ fun MessageBubbleOverlay( } ) } - .pointerInput(current.questionId) { + .pointerInput(current.conversationId) { detectTapGestures(onTap = { - onOpen(current.coupleId, current.questionId) + onOpen(current.coupleId, current.conversationId) viewModel.dismiss() }) },