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:
parent
c85e55a790
commit
e4cdbac7b1
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue