feat(chat): couple-shared premium gating for sending media + reactions

CouplePremiumChecker ORs self.isPremium with a live read of the partner's entitlement
doc (reactive). Composer photo/camera/voice buttons + keyboard GIF/sticker insert + the
reaction action gate on canSendMedia: locked buttons show a lock badge and route to the
existing PaywallScreen (with a chat_media paywall analytics event). Text/viewing/receiving
stay free. Rules: paired partner may read the entitlement doc. Verification pending deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:52:50 -05:00
parent f29d4699ca
commit 7a9ff31ae6
5 changed files with 136 additions and 18 deletions

View File

@ -0,0 +1,43 @@
package app.closer.core.billing
import app.closer.data.remote.FirestoreCollections
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
/**
* Couple-shared premium: media/reactions unlock if EITHER partner has an active subscription, so a
* single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium]
* with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry
* rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires.
*/
@Singleton
class CouplePremiumChecker @Inject constructor(
private val entitlementChecker: EntitlementChecker,
private val firestore: FirebaseFirestore
) {
fun coupleHasPremium(partnerId: String?): Flow<Boolean> =
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
mine || theirs
}.distinctUntilChanged()
private fun observePartnerPremium(partnerId: String): Flow<Boolean> = callbackFlow {
val ref = firestore.collection(FirestoreCollections.USERS).document(partnerId)
.collection(FirestoreCollections.Users.ENTITLEMENTS)
.document(FirestoreCollections.Users.ENTITLEMENT_PREMIUM_DOC)
val listener = ref.addSnapshotListener { snap, err ->
if (err != null || snap == null || !snap.exists()) { trySend(false); return@addSnapshotListener }
val premium = snap.getBoolean("premium") ?: false
val expiresAt = snap.getTimestamp("expiresAt")
val active = premium && (expiresAt == null || expiresAt.seconds > System.currentTimeMillis() / 1000)
trySend(active)
}
awaitClose { listener.remove() }
}
}

View File

@ -50,6 +50,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.ui.messages.components.ChatComposer import app.closer.ui.messages.components.ChatComposer
import app.closer.ui.messages.components.ChatDaySeparator import app.closer.ui.messages.components.ChatDaySeparator
import app.closer.ui.messages.components.ChatMessageRow import app.closer.ui.messages.components.ChatMessageRow
@ -67,6 +68,11 @@ fun ConversationScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val routeToPaywall: () -> Unit = {
viewModel.onMediaPaywallShown()
onNavigate(AppRoute.PAYWALL)
}
// Surface send failures as a snackbar. // Surface send failures as a snackbar.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.events.collect { snackbarHostState.showSnackbar(it) } viewModel.events.collect { snackbarHostState.showSnackbar(it) }
@ -151,7 +157,9 @@ fun ConversationScreen(
showAvatar = isLastInRun, showAvatar = isLastInRun,
showTimestamp = isLastInRun, showTimestamp = isLastInRun,
showSeen = seen, showSeen = seen,
canReact = state.canSendMedia,
onReact = { emoji -> viewModel.react(message.id, emoji) }, onReact = { emoji -> viewModel.react(message.id, emoji) },
onReactBlocked = routeToPaywall,
onDelete = { viewModel.deleteMessage(message.id) }, onDelete = { viewModel.deleteMessage(message.id) },
loadDecryptedMedia = viewModel::loadDecryptedMedia loadDecryptedMedia = viewModel::loadDecryptedMedia
) )
@ -181,6 +189,8 @@ fun ConversationScreen(
onSend = viewModel::sendMessage, onSend = viewModel::sendMessage,
onSendImage = viewModel::sendImage, onSendImage = viewModel::sendImage,
onSendVoice = viewModel::sendVoice, onSendVoice = viewModel::sendVoice,
canSendMedia = state.canSendMedia,
onUpgrade = routeToPaywall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) )
} }

View File

@ -3,6 +3,8 @@ package app.closer.ui.messages
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.analytics.AnalyticsTracker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.data.local.QuestionDao import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion import app.closer.data.local.mapper.toQuestion
import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionMessage
@ -41,6 +43,8 @@ data class ConversationUiState(
val canLoadMore: Boolean = false, val canLoadMore: Boolean = false,
val partnerReadAt: Long = 0L, val partnerReadAt: Long = 0L,
val partnerTyping: Boolean = false, val partnerTyping: Boolean = false,
/** Couple-shared premium: gates SENDING media + reactions (false until confirmed). */
val canSendMedia: Boolean = false,
val messageInput: String = "", val messageInput: String = "",
val isLoading: Boolean = true val isLoading: Boolean = true
) )
@ -53,6 +57,8 @@ class ConversationViewModel @Inject constructor(
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val questionDao: QuestionDao, private val questionDao: QuestionDao,
private val activeThreadMonitor: ActiveThreadMonitor, private val activeThreadMonitor: ActiveThreadMonitor,
private val couplePremiumChecker: CouplePremiumChecker,
private val analyticsTracker: AnalyticsTracker,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -107,6 +113,12 @@ class ConversationViewModel @Inject constructor(
it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false) it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false)
} }
// Couple-shared premium gate for sending media + reactions.
launch {
couplePremiumChecker.coupleHasPremium(partnerId)
.collect { premium -> _uiState.update { it.copy(canSendMedia = premium) } }
}
runCatching { repository.markRead(coupleId, conversationId, currentUserId) } runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
launch { launch {
@ -214,6 +226,11 @@ class ConversationViewModel @Inject constructor(
} }
} }
/** Called when a gated media/reaction action routes the user to the paywall. */
fun onMediaPaywallShown() {
analyticsTracker.trackPaywallViewed("chat_media")
}
fun deleteMessage(messageId: String) { fun deleteMessage(messageId: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.deleteMessage(coupleId, conversationId, messageId) } runCatching { repository.deleteMessage(coupleId, conversationId, messageId) }

View File

@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
@ -526,6 +527,8 @@ fun ChatComposer(
onSend: () -> Unit, onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit, onSendImage: (ByteArray) -> Unit,
onSendVoice: (ByteArray, Long) -> Unit, onSendVoice: (ByteArray, Long) -> Unit,
canSendMedia: Boolean = true,
onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -647,21 +650,23 @@ fun ChatComposer(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
IconButton( ComposerMediaButton(
icon = Icons.Filled.Image,
description = "Send a photo",
locked = !canSendMedia,
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.size(40.dp) onUpgrade = onUpgrade
) { )
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary) ComposerMediaButton(
} icon = Icons.Filled.PhotoCamera,
IconButton( description = "Take a photo",
locked = !canSendMedia,
onClick = { onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}, },
modifier = Modifier.size(40.dp) onUpgrade = onUpgrade
) { )
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
}
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content). // Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
Surface( Surface(
@ -673,23 +678,26 @@ fun ChatComposer(
RichContentTextField( RichContentTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
onImageReceived = { uri -> readAndSend(uri, compress = false) }, // Keyboard GIF/sticker/Bitmoji: gated → route to paywall instead of sending.
onImageReceived = { uri -> if (canSendMedia) readAndSend(uri, compress = false) else onUpgrade() },
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
hint = "Message…" hint = "Message…"
) )
} }
if (value.isBlank()) { if (value.isBlank()) {
// Mic when there's nothing typed (tap to record a voice note). // Mic when there's nothing typed (tap to record a voice note) — gated behind premium.
IconButton( ComposerMediaButton(
icon = Icons.Filled.Mic,
description = "Record a voice note",
locked = !canSendMedia,
size = 48.dp,
onClick = { onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}, },
modifier = Modifier.size(48.dp) onUpgrade = onUpgrade
) { )
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
}
} else { } else {
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) { IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary) Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
@ -701,6 +709,36 @@ fun ChatComposer(
/** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */ /** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */
private const val MAX_RECORDING_MS = 120_000L private const val MAX_RECORDING_MS = 120_000L
/** A composer media button with a lock badge + paywall routing when the couple isn't premium. */
@Composable
private fun ComposerMediaButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
locked: Boolean,
onClick: () -> Unit,
onUpgrade: () -> Unit,
size: androidx.compose.ui.unit.Dp = 40.dp
) {
Box {
IconButton(onClick = { if (locked) onUpgrade() else onClick() }, modifier = Modifier.size(size)) {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (locked) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
else MaterialTheme.colorScheme.primary
)
}
if (locked) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp).align(Alignment.BottomEnd)
)
}
}
}
private fun formatDuration(ms: Long): String { private fun formatDuration(ms: Long): String {
val totalSec = (ms / 1000).toInt() val totalSec = (ms / 1000).toInt()
val m = totalSec / 60 val m = totalSec / 60

View File

@ -176,7 +176,17 @@ service cloud.firestore {
// Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Entitlements written server-side only (RevenueCat webhook via Admin SDK).
// Client needs read access so FirestoreEntitlementChecker can observe premium state. // Client needs read access so FirestoreEntitlementChecker can observe premium state.
match /entitlements/{entitlementDoc} { match /entitlements/{entitlementDoc} {
allow read: if isOwner(uid); // Owner reads their own; a paired partner may also read it so premium can be shared
// across the couple (chat media unlocks if EITHER partner is premium).
allow read: if isOwner(uid)
|| (
request.auth != null
&& exists(/databases/$(database)/documents/users/$(uid))
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId
== get(/databases/$(database)/documents/users/$(uid)).data.coupleId
);
allow write: if false; allow write: if false;
} }