refactor(notifications): questionId → conversationId across bubble, deep link routing, FCM handling

- 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
This commit is contained in:
null 2026-06-24 16:14:07 -05:00
parent c85e55a790
commit e4cdbac7b1
6 changed files with 36 additions and 33 deletions

View File

@ -127,6 +127,7 @@ class MainActivity : AppCompatActivity() {
val coupleId = intent.getStringExtra("couple_id") ?: "" val coupleId = intent.getStringExtra("couple_id") ?: ""
val payload = PartnerNotificationPayload( val payload = PartnerNotificationPayload(
questionId = intent.getStringExtra("question_id"), questionId = intent.getStringExtra("question_id"),
conversationId = intent.getStringExtra("conversation_id"),
gameSessionId = intent.getStringExtra("game_session_id"), gameSessionId = intent.getStringExtra("game_session_id"),
capsuleId = intent.getStringExtra("capsule_id"), capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"), challengeId = intent.getStringExtra("challenge_id"),

View File

@ -62,9 +62,9 @@ class AppMessagingService : FirebaseMessagingService() {
// shown by the OS from the FCM notification block). For a chat message, surface the // 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. // draggable in-app bubble — unless the user is already reading that exact thread.
if (type == "chat_message") { if (type == "chat_message") {
val questionId = message.data["question_id"] val conversationId = message.data["conversation_id"]
if (questionId != null && questionId != activeThreadMonitor.activeQuestionId) { if (conversationId != null && conversationId != activeThreadMonitor.activeConversationId) {
messageBubbleController.show(coupleId, questionId, message.data["sender_avatar_url"]) messageBubbleController.show(coupleId, conversationId, message.data["sender_avatar_url"])
} }
return return
} }
@ -76,6 +76,7 @@ class AppMessagingService : FirebaseMessagingService() {
coupleId = coupleId, coupleId = coupleId,
payload = PartnerNotificationPayload( payload = PartnerNotificationPayload(
questionId = message.data["question_id"], questionId = message.data["question_id"],
conversationId = message.data["conversation_id"],
gameSessionId = message.data["game_session_id"], gameSessionId = message.data["game_session_id"],
capsuleId = message.data["capsule_id"], capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"], challengeId = message.data["challenge_id"],

View File

@ -16,18 +16,18 @@ class ActiveThreadMonitor @Inject constructor(
private val messageBubbleController: MessageBubbleController private val messageBubbleController: MessageBubbleController
) { ) {
@Volatile @Volatile
var activeQuestionId: String? = null var activeConversationId: String? = null
private set private set
fun enter(questionId: String) { fun enter(conversationId: String) {
if (questionId.isNotBlank()) { if (conversationId.isNotBlank()) {
activeQuestionId = questionId activeConversationId = conversationId
// Opening the conversation counts as reading it — clear any chat bubble for it. // Opening the conversation counts as reading it — clear any chat bubble for it.
messageBubbleController.dismissFor(questionId) messageBubbleController.dismissFor(conversationId)
} }
} }
fun leave(questionId: String) { fun leave(conversationId: String) {
if (activeQuestionId == questionId) activeQuestionId = null if (activeConversationId == conversationId) activeConversationId = null
} }
} }

View File

@ -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). */ /** State for the floating in-app message bubble (a chat-head shown over the app's own UI). */
data class IncomingMessageBubble( data class IncomingMessageBubble(
val coupleId: String, val coupleId: String,
val questionId: String, val conversationId: String,
val count: Int = 1, val count: Int = 1,
val avatarUrl: String? = null val avatarUrl: String? = null
) )
@ -25,14 +25,14 @@ class MessageBubbleController @Inject constructor() {
private val _bubble = MutableStateFlow<IncomingMessageBubble?>(null) private val _bubble = MutableStateFlow<IncomingMessageBubble?>(null)
val bubble: StateFlow<IncomingMessageBubble?> = _bubble.asStateFlow() val bubble: StateFlow<IncomingMessageBubble?> = _bubble.asStateFlow()
/** Show the bubble for [questionId], or bump its unread count if it's already showing. */ /** Show the bubble for [conversationId], or bump its unread count if it's already showing. */
fun show(coupleId: String, questionId: String, avatarUrl: String?) { fun show(coupleId: String, conversationId: String, avatarUrl: String?) {
if (coupleId.isBlank() || questionId.isBlank()) return if (coupleId.isBlank() || conversationId.isBlank()) return
_bubble.update { current -> _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) current.copy(count = current.count + 1, avatarUrl = avatarUrl ?: current.avatarUrl)
} else { } 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). */ /** Clear the bubble once its conversation is opened (the message has been read). */
fun dismissFor(questionId: String) { fun dismissFor(conversationId: String) {
_bubble.update { current -> if (current?.questionId == questionId) null else current } _bubble.update { current -> if (current?.conversationId == conversationId) null else current }
} }
} }

View File

@ -54,10 +54,10 @@ class PartnerNotificationManager @Inject constructor(
val settings = settingsRepository.settings.first() 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 && if (type == PartnerNotificationType.CHAT_MESSAGE &&
payload.questionId != null && payload.conversationId != null &&
payload.questionId == activeThreadMonitor.activeQuestionId payload.conversationId == activeThreadMonitor.activeConversationId
) return ) return
if (!isEnabled(type, settings)) return if (!isEnabled(type, settings)) return
@ -265,11 +265,11 @@ enum class PartnerNotificationType(
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
GENTLE_REMINDER -> AppRoute.DAILY_QUESTION GENTLE_REMINDER -> AppRoute.DAILY_QUESTION
DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION
// Open the actual discussion thread so the partner can reply in place. // Open the actual conversation so the partner can reply in place.
CHAT_MESSAGE -> if (payload.questionId != null && coupleId.isNotBlank()) { CHAT_MESSAGE -> if (payload.conversationId != null && coupleId.isNotBlank()) {
AppRoute.questionThread(coupleId, payload.questionId) AppRoute.conversation(coupleId, payload.conversationId)
} else { } else {
AppRoute.ANSWER_HISTORY AppRoute.MESSAGES
} }
OUTCOME_REMINDER -> AppRoute.SETTINGS OUTCOME_REMINDER -> AppRoute.SETTINGS
PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME
@ -308,6 +308,7 @@ enum class PartnerNotificationType(
*/ */
data class PartnerNotificationPayload( data class PartnerNotificationPayload(
val questionId: String? = null, val questionId: String? = null,
val conversationId: String? = null,
val gameSessionId: String? = null, val gameSessionId: String? = null,
val capsuleId: String? = null, val capsuleId: String? = null,
val challengeId: String? = null, val challengeId: String? = null,

View File

@ -59,7 +59,7 @@ class MessageBubbleViewModel @Inject constructor(
*/ */
@Composable @Composable
fun MessageBubbleOverlay( fun MessageBubbleOverlay(
onOpen: (coupleId: String, questionId: String) -> Unit, onOpen: (coupleId: String, conversationId: String) -> Unit,
viewModel: MessageBubbleViewModel = hiltViewModel() viewModel: MessageBubbleViewModel = hiltViewModel()
) { ) {
val bubble by viewModel.bubble.collectAsState() val bubble by viewModel.bubble.collectAsState()
@ -77,13 +77,13 @@ fun MessageBubbleOverlay(
val rightEdge = (maxXpx - sizePx - marginPx).coerceAtLeast(0f) val rightEdge = (maxXpx - sizePx - marginPx).coerceAtLeast(0f)
// Reset position to the right edge each time a new bubble appears. // Reset position to the right edge each time a new bubble appears.
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } var offsetX by remember(current.conversationId) { mutableFloatStateOf(rightEdge) }
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } var offsetY by remember(current.conversationId) { mutableFloatStateOf(maxYpx * 0.32f) }
// The bubble persists until the message is read — opening the conversation clears it (via // 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. // ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
var dragging by remember(current.questionId) { mutableStateOf(false) } var dragging by remember(current.conversationId) { mutableStateOf(false) }
var nearDismiss by remember(current.questionId) { mutableStateOf(false) } var nearDismiss by remember(current.conversationId) { mutableStateOf(false) }
val dismissZonePx = with(density) { 150.dp.toPx() } val dismissZonePx = with(density) { 150.dp.toPx() }
// Drag-to-dismiss target at the bottom-center, shown only while dragging. // Drag-to-dismiss target at the bottom-center, shown only while dragging.
@ -117,7 +117,7 @@ fun MessageBubbleOverlay(
.clip(CircleShape) .clip(CircleShape)
.background(CloserPalette.PurpleRich) .background(CloserPalette.PurpleRich)
.border(2.5.dp, Color.White, CircleShape) .border(2.5.dp, Color.White, CircleShape)
.pointerInput(current.questionId) { .pointerInput(current.conversationId) {
detectDragGestures( detectDragGestures(
onDragStart = { dragging = true }, onDragStart = { dragging = true },
onDrag = { change, drag -> onDrag = { change, drag ->
@ -142,9 +142,9 @@ fun MessageBubbleOverlay(
} }
) )
} }
.pointerInput(current.questionId) { .pointerInput(current.conversationId) {
detectTapGestures(onTap = { detectTapGestures(onTap = {
onOpen(current.coupleId, current.questionId) onOpen(current.coupleId, current.conversationId)
viewModel.dismiss() viewModel.dismiss()
}) })
}, },