feat(activity): ActivityScreen timeline, HomeScreen activity feed, HomeViewModel wiring, PartnerNotificationManager, sendThinkingOfYouCallable Cloud Function
This commit is contained in:
parent
e74b6f59af
commit
d87603211a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; } });
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -15,6 +15,7 @@ export {
|
|||
sendPartnerAnsweredNotification,
|
||||
} from './notifications/reminders'
|
||||
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
|
||||
export { sendThinkingOfYouCallable } from './notifications/sendThinkingOfYouCallable'
|
||||
export {
|
||||
sendChallengeDayReminders,
|
||||
unlockDueMemoryCapsules,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
Loading…
Reference in New Issue