diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index bc9f3dad..9e2d17e7 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -241,6 +241,13 @@ enum class PartnerNotificationType( channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), + // Affectionate one-tap nudge from the partner sheet (no deeper target โ€” opens Home). + THINKING_OF_YOU( + title = "Your partner is thinking of you ๐Ÿ’œ", + body = "Tap to send one back.", + channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, + rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER + ), DAILY_QUESTION_REMINDER( title = "Tonight's question is waiting.", body = "Answer together before it expires.", @@ -316,6 +323,7 @@ enum class PartnerNotificationType( CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE GENTLE_REMINDER -> AppRoute.DAILY_QUESTION + THINKING_OF_YOU -> AppRoute.HOME DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION // Open the actual conversation so the partner can reply in place. CHAT_MESSAGE -> if (payload.conversationId != null && coupleId.isNotBlank()) { @@ -347,6 +355,7 @@ enum class PartnerNotificationType( "challenge_day_ready", "challenge_waiting" -> CHALLENGE_WAITING "memory_capsule_unlocked" -> CAPSULE_UNLOCKED "gentle_reminder" -> GENTLE_REMINDER + "thinking_of_you" -> THINKING_OF_YOU // Server emits both 'daily_question' (assignment) and 'daily_question_reminder' โ€” both open Today. "daily_question", "daily_question_reminder" -> DAILY_QUESTION_REMINDER "chat_message" -> CHAT_MESSAGE diff --git a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt index f4f25b94..b1d55e13 100644 --- a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt +++ b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -26,6 +27,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.domain.model.ActivityItem +import app.closer.core.navigation.AppRoute import app.closer.data.remote.FirestoreActivityDataSource import androidx.compose.ui.res.painterResource import app.closer.R @@ -99,17 +101,38 @@ fun ActivityScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(state.items, key = { it.id }) { item -> - ActivityRow(item) + val route = routeForActivityType(item.type) + ActivityRow(item, onClick = route?.let { { onNavigate(it) } }) } } } } } +/** + * Maps an activity item's type to the relevant hub to open on tap. Affection/reminder items + * (thinking_of_you, gentle_reminder, streak, reengagement, โ€ฆ) carry no deeper target, so they + * return null and the row stays non-tappable. + */ +private fun routeForActivityType(type: String): String? { + val t = type.lowercase() + return when { + "message" in t || "chat" in t -> AppRoute.MESSAGES + "game" in t -> AppRoute.PLAY + "capsule" in t -> AppRoute.MEMORY_LANE + "challenge" in t -> AppRoute.CONNECTION_CHALLENGES + "date" in t -> AppRoute.DATE_MATCHES + "answer" in t || "reveal" in t || "question" in t || "daily" in t -> AppRoute.DAILY_QUESTION + else -> null + } +} + @Composable -private fun ActivityRow(item: ActivityItem) { +private fun ActivityRow(item: ActivityItem, onClick: (() -> Unit)? = null) { CloserCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it }, containerColor = if (item.read) closerCardColor() else CloserPalette.PurpleSoft ) { Column(modifier = Modifier.padding(16.dp)) { diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index dd3dad4a..a81d1a90 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -1,6 +1,7 @@ package app.closer.ui.home import app.closer.core.navigation.AppRoute +import app.closer.crypto.FieldEncryptor import app.closer.domain.model.Question import app.closer.domain.model.QuestionCategory import androidx.annotation.DrawableRes @@ -51,7 +52,11 @@ import app.closer.domain.model.OutcomeDay import app.closer.ui.components.OutcomeCheckInDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -59,6 +64,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -110,6 +116,15 @@ fun HomeScreen( } } + LaunchedEffect(state.nudgeResult) { + state.nudgeResult?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeNudgeResult() + } + } + + var showPartnerSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.needsRecovery) { if (state.needsRecovery) { onNavigate(app.closer.core.navigation.AppRoute.RECOVERY) @@ -178,10 +193,22 @@ fun HomeScreen( ) } + if (showPartnerSheet && state.isPaired) { + PartnerQuickActionsSheet( + state = state, + onDismiss = { showPartnerSheet = false }, + onNavigate = onNavigate, + onThinkingOfYou = viewModel::sendThinkingOfYou + ) + } + HomeContent( state = state, snackbarHostState = snackbarHostState, onNavigate = onNavigate, + onPartnerBubble = { + if (state.isPaired) showPartnerSheet = true else onNavigate(AppRoute.CREATE_INVITE) + }, onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) }, onPacks = { onNavigate(AppRoute.QUESTION_PACKS) }, onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) }, @@ -232,6 +259,7 @@ private fun HomeContent( state: HomeUiState, snackbarHostState: SnackbarHostState, onNavigate: (String) -> Unit, + onPartnerBubble: () -> Unit = {}, onDailyQuestion: () -> Unit, onPacks: () -> Unit, onCategory: (String) -> Unit, @@ -306,9 +334,7 @@ private fun HomeContent( streakCount = state.streakCount, isPaired = state.isPaired, unreadActivityCount = state.unreadActivityCount, - onTogether = { - onNavigate(if (state.isPaired) AppRoute.ACTIVITY else AppRoute.CREATE_INVITE) - } + onTogether = onPartnerBubble ) when { @@ -664,6 +690,130 @@ private fun partnerInitials(name: String?): String { return initials.uppercase() } +/** + * Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap + * actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PartnerQuickActionsSheet( + state: HomeUiState, + onDismiss: () -> Unit, + onNavigate: (String) -> Unit, + onThinkingOfYou: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + // A locked/blank name (E2EE key missing on this device) must never surface ciphertext/placeholder. + val name = state.partnerName + ?.takeIf { it.isNotBlank() && it != FieldEncryptor.LOCKED_PLACEHOLDER } + ?: "Your partner" + val glance = remember(state.streakCount, state.togetherSince) { + buildList { + if (state.streakCount > 0) { + add("๐Ÿ’œ ${state.streakCount} ${if (state.streakCount == 1) "night" else "nights"}") + } + if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}") + }.joinToString(" ยท ") + } + val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = openPartnerPage) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PartnerHeaderBubble( + partnerName = name, + partnerPhotoUrl = state.partnerPhotoUrl, + isPaired = true, + onClick = openPartnerPage + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = name, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (glance.isNotBlank()) { + Text( + text = glance, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp) + + PartnerSheetAction("๐Ÿ’œ", "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou) + PartnerSheetAction("๐Ÿ’ฌ", "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() } + PartnerSheetAction( + "โœจ", + "Together", + trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" } + ) { onNavigate(AppRoute.ACTIVITY); onDismiss() } + PartnerSheetAction("โš™๏ธ", "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() } + } + } +} + +@Composable +private fun PartnerSheetAction( + emoji: String, + label: String, + enabled: Boolean = true, + trailing: String? = null, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 8.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = emoji, style = MaterialTheme.typography.titleMedium) + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f) + ) + if (trailing != null) { + Surface(shape = CircleShape, color = CloserPalette.PinkBright) { + Text( + text = trailing, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + } + } + } +} + +private fun formatMonthYear(millis: Long): String = + java.text.SimpleDateFormat("MMM yyyy", java.util.Locale.getDefault()).format(java.util.Date(millis)) + @Composable private fun PartnerActivationCard( onInvite: () -> Unit, diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 6fabc6b1..b945c8f3 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -29,6 +29,7 @@ import app.closer.domain.repository.OutcomeRepository import app.closer.domain.repository.SettingsRepository import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.FirebaseFunctionsException import dagger.hilt.android.lifecycle.HiltViewModel import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput import app.closer.ui.home.HomePriorityEngine.Priority @@ -134,6 +135,8 @@ data class HomeUiState( val partnerName: String? = null, val partnerPhotoUrl: String? = null, val streakCount: Int = 0, + /** When the couple was created (millis), for the partner-sheet "together since" glance. 0 = unknown. */ + val togetherSince: Long = 0L, val isPaired: Boolean = false, val primaryAction: HomeAction? = null, val secondaryActions: List = emptyList(), @@ -157,6 +160,9 @@ data class HomeUiState( val hasUnlockedCapsule: Boolean = false, val weeklyRecapReady: Boolean = false, val reminderSentEvent: Boolean = false, + /** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */ + val isSendingNudge: Boolean = false, + val nudgeResult: String? = null, // Outcome check-in state val outcomeSubmitSuccess: Boolean = false, val outcomeError: String? = null, @@ -332,6 +338,7 @@ class HomeViewModel @Inject constructor( partnerName = partnerName, partnerPhotoUrl = partnerPhotoUrl, streakCount = couple?.streakCount ?: 0, + togetherSince = couple?.createdAt ?: 0L, isPaired = couple != null, coupleId = coupleId, partnerLeftEvent = false, @@ -481,6 +488,37 @@ class HomeViewModel @Inject constructor( fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) } + /** + * Send a "thinking of you ๐Ÿ’œ" nudge to the partner via the callable (rate-limited + quiet-hours-aware + * server-side). Fails gracefully: maps the error to a friendly one-shot message, never crashes, and + * the rest of the sheet works regardless (and before the function is deployed). + */ + fun sendThinkingOfYou() { + val state = _uiState.value + if (!state.isPaired || state.isSendingNudge) return + _uiState.update { it.copy(isSendingNudge = true) } + viewModelScope.launch { + val message = runCatching { + functions.getHttpsCallable("sendThinkingOfYouCallable").call().await() + }.fold( + onSuccess = { "Sent ๐Ÿ’œ" }, + onFailure = { e -> + Log.w(TAG, "Thinking of you failed", e) + if ((e as? FirebaseFunctionsException)?.code == + FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED + ) { + "You've sent a few already โ€” give it a moment ๐Ÿ’œ" + } else { + "Couldn't send right now. Try again." + } + } + ) + _uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) } + } + } + + fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) } + fun consumeStreakMilestone() = _uiState.update { it.copy(streakMilestone = null) } private fun observeAnswers() { diff --git a/functions/dist/index.js b/functions/dist/index.js index c983d2c5..5df51bda 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. // Handlers call admin.firestore()/messaging() lazily at invocation time, so a @@ -52,6 +52,8 @@ Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable"); Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } }); +var sendThinkingOfYouCallable_1 = require("./notifications/sendThinkingOfYouCallable"); +Object.defineProperty(exports, "sendThinkingOfYouCallable", { enumerable: true, get: function () { return sendThinkingOfYouCallable_1.sendThinkingOfYouCallable; } }); var gameRetention_1 = require("./notifications/gameRetention"); Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, get: function () { return gameRetention_1.sendChallengeDayReminders; } }); Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } }); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index bbc17998..d632c95e 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/dist/notifications/sendThinkingOfYouCallable.js b/functions/dist/notifications/sendThinkingOfYouCallable.js new file mode 100644 index 00000000..521b8940 --- /dev/null +++ b/functions/dist/notifications/sendThinkingOfYouCallable.js @@ -0,0 +1,145 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sendThinkingOfYouCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("./quietHours"); +const THINKING_OF_YOU_MAX_PER_DAY = 10; +const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h +/** + * "Thinking of you ๐Ÿ’œ" โ€” a one-tap affectionate nudge from the partner sheet. + * + * Partner-initiated (like the gentle reminder), so it is guarded by a rate limit + quiet hours rather + * than a per-type opt-out: + * - Per-user rolling 24h cap (transaction on `rate_limits/{uid}_thinking_of_you`) so it can't be looped. + * - Quiet hours: during the recipient's window the FCM push is suppressed, but the in-app record is + * still written so the love is waiting when they wake. (Improvement over the gentle reminder.) + * + * Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push. + */ +exports.sendThinkingOfYouCallable = functions.https.onCall(async (_data, context) => { + var _a, _b, _c, _d, _e; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + if (!context.app) { + throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); + } + const db = admin.firestore(); + // โ”€โ”€ Resolve couple + partner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const userDoc = await db.collection('users').doc(callerId).get(); + const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.'); + } + const coupleDoc = await db.collection('couples').doc(coupleId).get(); + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.'); + } + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); + const partnerId = userIds.find((id) => id !== callerId); + if (!partnerId) { + throw new functions.https.HttpsError('failed-precondition', 'No partner found.'); + } + // โ”€โ”€ Per-user rolling rate limit (transactional) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const now = admin.firestore.Timestamp.now(); + const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`); + const throttle = await db.runTransaction(async (tx) => { + const snap = await tx.get(rateLimitRef); + const data = snap.data(); + let windowStart = now; + let count = 0; + if (snap.exists && data) { + windowStart = data.windowStart; + count = typeof data.count === 'number' ? data.count : 0; + if (now.toMillis() - windowStart.toMillis() >= THINKING_OF_YOU_WINDOW_MS) { + windowStart = now; + count = 0; + } + } + if (count >= THINKING_OF_YOU_MAX_PER_DAY) { + return { allowed: false }; + } + tx.set(rateLimitRef, { count: count + 1, windowStart, updatedAt: now }, { merge: true }); + return { allowed: true }; + }); + if (!throttle.allowed) { + throw new functions.https.HttpsError('resource-exhausted', "You've sent a few already โ€” give it a moment."); + } + // โ”€โ”€ In-app record (always โ€” shows in-app + the Together feed) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await db.collection('users').doc(partnerId).collection('notification_queue').add({ + type: 'thinking_of_you', + title: 'Your partner is thinking of you ๐Ÿ’œ', + body: 'Tap to send one back.', + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + // โ”€โ”€ Quiet hours: keep the in-app record, suppress the disruptive push โ”€โ”€โ”€โ”€โ”€ + const partnerDoc = await db.collection('users').doc(partnerId).get(); + if ((0, quietHours_1.recipientInQuietHours)(partnerDoc.data())) { + console.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours โ€” push suppressed (in-app kept)`); + return { sent: true }; + } + // โ”€โ”€ FCM push โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const tokens = []; + const legacy = (_e = partnerDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken; + if (typeof legacy === 'string' && legacy.length > 0) + tokens.push(legacy); + const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get(); + tokenSnap.docs.forEach((d) => { + var _a; + const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token; + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) + tokens.push(t); + }); + if (tokens.length > 0) { + const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({ + token, + notification: { title: 'Your partner is thinking of you ๐Ÿ’œ', body: 'Tap to send one back.' }, + android: { notification: { channelId: 'partner_activity' } }, // E-OBS + data: { type: 'thinking_of_you', couple_id: coupleId }, + }))); + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.warn(`[sendThinkingOfYouCallable] FCM failed for token ${tokens[i]}:`, r.reason); + } + }); + } + console.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`); + return { sent: true }; +}); +//# sourceMappingURL=sendThinkingOfYouCallable.js.map \ No newline at end of file diff --git a/functions/dist/notifications/sendThinkingOfYouCallable.js.map b/functions/dist/notifications/sendThinkingOfYouCallable.js.map new file mode 100644 index 00000000..63d37943 --- /dev/null +++ b/functions/dist/notifications/sendThinkingOfYouCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index 4abbaaef..1b098388 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ export { sendPartnerAnsweredNotification, } from './notifications/reminders' export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable' +export { sendThinkingOfYouCallable } from './notifications/sendThinkingOfYouCallable' export { sendChallengeDayReminders, unlockDueMemoryCapsules, diff --git a/functions/src/notifications/sendThinkingOfYouCallable.ts b/functions/src/notifications/sendThinkingOfYouCallable.ts new file mode 100644 index 00000000..fa4d7cbc --- /dev/null +++ b/functions/src/notifications/sendThinkingOfYouCallable.ts @@ -0,0 +1,121 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { recipientInQuietHours } from './quietHours' + +const THINKING_OF_YOU_MAX_PER_DAY = 10 +const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000 // rolling 24h + +/** + * "Thinking of you ๐Ÿ’œ" โ€” a one-tap affectionate nudge from the partner sheet. + * + * Partner-initiated (like the gentle reminder), so it is guarded by a rate limit + quiet hours rather + * than a per-type opt-out: + * - Per-user rolling 24h cap (transaction on `rate_limits/{uid}_thinking_of_you`) so it can't be looped. + * - Quiet hours: during the recipient's window the FCM push is suppressed, but the in-app record is + * still written so the love is waiting when they wake. (Improvement over the gentle reminder.) + * + * Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push. + */ +export const sendThinkingOfYouCallable = functions.https.onCall(async (_data, context) => { + const callerId = context.auth?.uid + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') + } + if (!context.app) { + throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') + } + + const db = admin.firestore() + + // โ”€โ”€ Resolve couple + partner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const userDoc = await db.collection('users').doc(callerId).get() + const coupleId = userDoc.data()?.coupleId as string | undefined + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.') + } + const coupleDoc = await db.collection('couples').doc(coupleId).get() + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.') + } + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + const partnerId = userIds.find((id) => id !== callerId) + if (!partnerId) { + throw new functions.https.HttpsError('failed-precondition', 'No partner found.') + } + + // โ”€โ”€ Per-user rolling rate limit (transactional) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const now = admin.firestore.Timestamp.now() + const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`) + const throttle = await db.runTransaction(async (tx) => { + const snap = await tx.get(rateLimitRef) + const data = snap.data() + let windowStart = now + let count = 0 + if (snap.exists && data) { + windowStart = data.windowStart as admin.firestore.Timestamp + count = typeof data.count === 'number' ? data.count : 0 + if (now.toMillis() - windowStart.toMillis() >= THINKING_OF_YOU_WINDOW_MS) { + windowStart = now + count = 0 + } + } + if (count >= THINKING_OF_YOU_MAX_PER_DAY) { + return { allowed: false } + } + tx.set(rateLimitRef, { count: count + 1, windowStart, updatedAt: now }, { merge: true }) + return { allowed: true } + }) + if (!throttle.allowed) { + throw new functions.https.HttpsError( + 'resource-exhausted', + "You've sent a few already โ€” give it a moment." + ) + } + + // โ”€โ”€ In-app record (always โ€” shows in-app + the Together feed) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await db.collection('users').doc(partnerId).collection('notification_queue').add({ + type: 'thinking_of_you', + title: 'Your partner is thinking of you ๐Ÿ’œ', + body: 'Tap to send one back.', + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // โ”€โ”€ Quiet hours: keep the in-app record, suppress the disruptive push โ”€โ”€โ”€โ”€โ”€ + const partnerDoc = await db.collection('users').doc(partnerId).get() + if (recipientInQuietHours(partnerDoc.data())) { + console.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours โ€” push suppressed (in-app kept)`) + return { sent: true } + } + + // โ”€โ”€ FCM push โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const tokens: string[] = [] + const legacy = partnerDoc.data()?.fcmToken + if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) + const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() + tokenSnap.docs.forEach((d) => { + const t = d.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) + }) + + if (tokens.length > 0) { + const results = await Promise.allSettled( + tokens.map((token) => + admin.messaging().send({ + token, + notification: { title: 'Your partner is thinking of you ๐Ÿ’œ', body: 'Tap to send one back.' }, + android: { notification: { channelId: 'partner_activity' } }, // E-OBS + data: { type: 'thinking_of_you', couple_id: coupleId }, + }) + ) + ) + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.warn(`[sendThinkingOfYouCallable] FCM failed for token ${tokens[i]}:`, r.reason) + } + }) + } + + console.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`) + return { sent: true } +})