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 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"),

View File

@ -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"],

View File

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

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). */
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<IncomingMessageBubble?>(null)
val bubble: StateFlow<IncomingMessageBubble?> = _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 }
}
}

View File

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

View File

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