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.unit.dp
|
||||
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.ChatDaySeparator
|
||||
import app.closer.ui.messages.components.ChatMessageRow
|
||||
|
|
@ -67,6 +68,11 @@ fun ConversationScreen(
|
|||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val routeToPaywall: () -> Unit = {
|
||||
viewModel.onMediaPaywallShown()
|
||||
onNavigate(AppRoute.PAYWALL)
|
||||
}
|
||||
|
||||
// Surface send failures as a snackbar.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { snackbarHostState.showSnackbar(it) }
|
||||
|
|
@ -151,7 +157,9 @@ fun ConversationScreen(
|
|||
showAvatar = isLastInRun,
|
||||
showTimestamp = isLastInRun,
|
||||
showSeen = seen,
|
||||
canReact = state.canSendMedia,
|
||||
onReact = { emoji -> viewModel.react(message.id, emoji) },
|
||||
onReactBlocked = routeToPaywall,
|
||||
onDelete = { viewModel.deleteMessage(message.id) },
|
||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||
)
|
||||
|
|
@ -181,6 +189,8 @@ fun ConversationScreen(
|
|||
onSend = viewModel::sendMessage,
|
||||
onSendImage = viewModel::sendImage,
|
||||
onSendVoice = viewModel::sendVoice,
|
||||
canSendMedia = state.canSendMedia,
|
||||
onUpgrade = routeToPaywall,
|
||||
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.ViewModel
|
||||
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.mapper.toQuestion
|
||||
import app.closer.domain.model.QuestionMessage
|
||||
|
|
@ -41,6 +43,8 @@ data class ConversationUiState(
|
|||
val canLoadMore: Boolean = false,
|
||||
val partnerReadAt: Long = 0L,
|
||||
val partnerTyping: Boolean = false,
|
||||
/** Couple-shared premium: gates SENDING media + reactions (false until confirmed). */
|
||||
val canSendMedia: Boolean = false,
|
||||
val messageInput: String = "",
|
||||
val isLoading: Boolean = true
|
||||
)
|
||||
|
|
@ -53,6 +57,8 @@ class ConversationViewModel @Inject constructor(
|
|||
private val userRepository: UserRepository,
|
||||
private val questionDao: QuestionDao,
|
||||
private val activeThreadMonitor: ActiveThreadMonitor,
|
||||
private val couplePremiumChecker: CouplePremiumChecker,
|
||||
private val analyticsTracker: AnalyticsTracker,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -107,6 +113,12 @@ class ConversationViewModel @Inject constructor(
|
|||
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) }
|
||||
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
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.Delete
|
||||
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.Pause
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
|
|
@ -526,6 +527,8 @@ fun ChatComposer(
|
|||
onSend: () -> Unit,
|
||||
onSendImage: (ByteArray) -> Unit,
|
||||
onSendVoice: (ByteArray, Long) -> Unit,
|
||||
canSendMedia: Boolean = true,
|
||||
onUpgrade: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -647,21 +650,23 @@ fun ChatComposer(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
IconButton(
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.Image,
|
||||
description = "Send a photo",
|
||||
locked = !canSendMedia,
|
||||
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.PhotoCamera,
|
||||
description = "Take a photo",
|
||||
locked = !canSendMedia,
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
|
||||
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
|
||||
Surface(
|
||||
|
|
@ -673,23 +678,26 @@ fun ChatComposer(
|
|||
RichContentTextField(
|
||||
value = value,
|
||||
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),
|
||||
hint = "Message…"
|
||||
)
|
||||
}
|
||||
|
||||
if (value.isBlank()) {
|
||||
// Mic when there's nothing typed (tap to record a voice note).
|
||||
IconButton(
|
||||
// Mic when there's nothing typed (tap to record a voice note) — gated behind premium.
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.Mic,
|
||||
description = "Record a voice note",
|
||||
locked = !canSendMedia,
|
||||
size = 48.dp,
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
|
||||
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. */
|
||||
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 {
|
||||
val totalSec = (ms / 1000).toInt()
|
||||
val m = totalSec / 60
|
||||
|
|
|
|||
|
|
@ -176,7 +176,17 @@ service cloud.firestore {
|
|||
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
|
||||
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue