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:
parent
f29d4699ca
commit
7a9ff31ae6
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue