feat(activity): ActivityScreen timeline, HomeScreen activity feed, HomeViewModel wiring, PartnerNotificationManager, sendThinkingOfYouCallable Cloud Function

This commit is contained in:
null 2026-06-30 03:54:01 -05:00
parent e74b6f59af
commit d87603211a
10 changed files with 498 additions and 8 deletions

View File

@ -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

View File

@ -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)) {

View File

@ -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,

View File

@ -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<HomeAction> = 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() {

View File

@ -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; } });

View File

@ -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"}
{"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"}

View File

@ -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

View File

@ -0,0 +1 @@
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}

View File

@ -15,6 +15,7 @@ export {
sendPartnerAnsweredNotification,
} from './notifications/reminders'
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
export { sendThinkingOfYouCallable } from './notifications/sendThinkingOfYouCallable'
export {
sendChallengeDayReminders,
unlockDueMemoryCapsules,

View File

@ -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 }
})