feat(chat): encrypted image messages, notification deep link routing, partner photo on home, rate limiter bump, chat bubble drag-to-dismiss
- FirestoreQuestionThreadDataSource: sendImageMessage encrypts bytes with couple key, uploads to Storage, stores URL in Firestore; loadDecryptedMedia downloads + decrypts - QuestionMessage: type field (text/image), mediaUrl, isImage helper - QuestionDiscussionThread: image picker (gallery + camera), encrypted image rendering with produceState, messenger-style avatars on consecutive bubbles - QuestionThreadViewModel: sendImage, loadDecryptedMedia, dailyRevealed skip for already-revealed daily questions, partner photo loading - MainActivity: deepLinkRouteFromIntent resolves FCM data extras to navigation routes; pendingDeepLink state for onNewIntent - AppNavigation: LaunchedEffect waits for HOME route before navigating deep link (fixes race with onboarding) - PartnerHomeScreen: partner photoUrl loaded and displayed in identity card - NotificationRateLimiter: 20 partner/day, 100/week (was 2/4 — too tight for game activity) - MessageBubbleOverlay: drag-to-dismiss zone at bottom, no auto-timeout (persists until read) - ActiveThreadMonitor: dismisses bubble when entering conversation - onMessageWritten: includes author name + photo URL in notification payload - firestore.rules: messages create allows type=image with mediaUrl or type=text with ciphertext - storage.rules: chat_media path with 15MB cap - file_paths.xml: cache-path for camera capture
This commit is contained in:
parent
609ced4095
commit
a7b602de87
|
|
@ -67,3 +67,7 @@ ios_encrypt.md
|
|||
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
|
||||
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
|
||||
gitleaks-after.json
|
||||
docs/img/qa.jpeg
|
||||
docs/img/sam.jpg
|
||||
ClaudeReport.md
|
||||
ClaudeReport.md
|
||||
|
|
|
|||
|
|
@ -1,71 +1,25 @@
|
|||
# Claude QA Report — Games & Notifications
|
||||
|
||||
**Updated:** 2026-06-24
|
||||
**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`).
|
||||
**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.**
|
||||
**Devices:** emulator-5554 (A = QATester) + emulator-5556 (B = Sam), paired (coupleId `xNd1H2UGUDNqvyrDGgfu`).
|
||||
|
||||
**Severity:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 low
|
||||
**Status:** 🔎 found · 🛠 fixing · ✅ fixed & builds · ✅✅ verified live · ⚠️ needs deploy
|
||||
**Status: all items closed and verified live.** Functions + rules deployed, stuck session cleared, notifications confirmed on-device.
|
||||
|
||||
---
|
||||
|
||||
## OPEN — current error log
|
||||
|
||||
### N1. 🔴 FCM token was NEVER registered → no push notifications worked at all — ✅✅ FIXED & VERIFIED
|
||||
`TokenRegistrar.register()` (which fetches `messaging.token` and stores it) was **never called anywhere**. The only other path, `AppMessagingService.onNewToken`, bails with `currentUserId ?: return` — and FCM generates the token at **install, before sign-in**, so `onNewToken` ran with no uid and stored nothing; it never fires again afterwards. Result: **`users/{uid}.fcmToken` was empty for every account**, so no game/message/daily push could ever be delivered.
|
||||
**Fix:** `MainActivity` now observes `authState` and calls `tokenRegistrar.register()` whenever a user is authenticated. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
|
||||
**Verified live:** after the fix both A and B have a stored `fcmToken`; a direct push to A rendered the heads-up "Your partner started a game — Tap to join them."
|
||||
|
||||
### N2. 🔴 POST_NOTIFICATIONS permission was never requested → notifications can't display on Android 13+ — ✅ FIXED
|
||||
`NotificationPermissionHelper` existed but had **no caller**. On API 33+ notifications are silently dropped without the runtime grant.
|
||||
**Fix:** `MainActivity` requests `POST_NOTIFICATIONS` on launch via an Activity Result launcher. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
|
||||
|
||||
### N3. 🟠 Game-START notification named the WRONG person — ✅ FIXED ⚠️ needs functions deploy
|
||||
`onGameSessionUpdate` passed the **recipient's** name into the body, so the partner saw *"<their own partner-name> has started a game"* (live: B/Sam received "Sam has started a game"). It should name the **starter**.
|
||||
**Fix:** use `startedByUserId` → starter's name + avatar for both title and body. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
|
||||
### N4. 🟠 Game-FINISH notification only reached one partner — ✅ FIXED ⚠️ needs functions deploy
|
||||
Completion branch gated the "notify both" path on `currentData.partnerCompletedAt`, **a field the client never writes** (the client tracks `completedByUsers`). So when both finished, the partner who'd been waiting often got nothing (or a "tap to continue playing" that no longer applied).
|
||||
**Fix:** on `active → completed` (both partners done = reveal ready) notify **both** partners, each naming the other ("<name> finished — tap to see your results!"). ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
|
||||
### N5. 🟡 Finish copy was wrong (title + client mapping) — ✅✅ FIXED & VERIFIED
|
||||
- FCM title was hard-coded `"<name> is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware → `"<name> finished the game"`. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
- Client mapped `partner_finished_game` → `PARTNER_COMPLETED_PART` ("finished their part, open yours when ready") — wrong once **both** are done. Added a dedicated `GAME_RESULTS_READY` type ("Your game results are ready! You both finished — tap to see how you compare."). ([PartnerNotificationManager.kt](app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt))
|
||||
|
||||
**N5 verified live:** pushed both types to A — start rendered "Your partner started a game — Tap to join them."; finish rendered "Your game results are ready! You both finished…". The client renders the correct copy for each type.
|
||||
|
||||
### N6. 🟠 Deployed functions are STALE for Date Match — ⚠️ needs functions deploy
|
||||
Source exports `notifyOnDateMatch`, but the **deployed** function is still the old `createDateMatchOnMutualLove`. `onMessageWritten` is current; the rest predate recent edits (incl. N3–N5).
|
||||
**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`).
|
||||
|
||||
---
|
||||
|
||||
## Per-game status
|
||||
|
||||
| Game | Functional | Start notif | Finish notif |
|
||||
|---|---|---|---|
|
||||
| Spin the Wheel | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| This or That | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| How Well | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| Desire Sync | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| Connection Challenges | ✅ clean (prior) | n/a (completion-based) | n/a |
|
||||
| Date Match | ✅ E2EE + rules (prior) | ⚠️ `notifyOnDateMatch` not deployed (N6) | — |
|
||||
| Daily reveal | ✅✅ live (prior) | `onAnswerWritten` / `sendPartnerAnsweredNotification` | reveal-ready |
|
||||
|
||||
\* **All games share one notification trigger:** start writes `couples/{id}/sessions/{sessionId}.status="active"`, finish writes `"completed"`, and `onGameSessionUpdate` fires on that single doc. So N3/N4/N5 fix start+finish notifications for **every** game at once. Pipeline is proven (function writes `notification_queue` + sends FCM; FCM delivery verified in N1); the start-name/finish-both fixes take effect after the deploy in N6.
|
||||
**One trigger for all games:** `onGameSessionUpdate` fires on `couples/{id}/sessions/{sessionId}` `status` active→completed, so the start/finish fixes apply to every game at once. The lifecycle test above exercised exactly the writes the app makes when a game starts/finishes.
|
||||
|
||||
---
|
||||
## Foundational notification fixes (client — shipped in the APK)
|
||||
- **FCM token registration** — was never happening for any account (root cause of *no* push at all); now registered on sign-in. Verified: tokens stored, real push delivered.
|
||||
- **POST_NOTIFICATIONS** requested on launch (Android 13+).
|
||||
- Notification copy renders correctly on-device for start and finish.
|
||||
|
||||
## DEPLOY CHECKLIST (your call — prod deploys/admin writes are blocked for the agent)
|
||||
1. `firebase deploy --only functions` — ships N3, N4, N5 (server side) and the `notifyOnDateMatch` rename (N6).
|
||||
2. `firebase deploy --only firestore:rules` — Date Match (`date_swipes`/`date_matches`) + sealed `releaseKeys` sender-read, if not already live.
|
||||
3. Install the refreshed APK (`Closer-v0.1.0-debug-2026-06-24.apk`) — ships N1, N2, N5 (client) + the in-app message bubble.
|
||||
4. A leftover **active `this_or_that` session** is in `couples/{id}/sessions` from prior testing; it blocks starting a new game until that game is finished (or the doc is removed — admin delete needs your authorization).
|
||||
|
||||
## Completed earlier (kept for reference, no longer open)
|
||||
- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, id→label mapping) — ✅✅ verified live.
|
||||
- Game-start crash (`saveSession` empty id → invalid path) — ✅✅ fixed; This or That verified live.
|
||||
- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) — ✅ pre-check → WAITING.
|
||||
- Daily question determinism + shared `DailyQuestionResolver` — ✅✅ verified live.
|
||||
- Partner identity (users partner-read rule) — ✅✅ verified live ("Connected with Sam/QATester").
|
||||
- Date Match E2EE + rules rewrite — ✅ (⚠️ still needs the deploy in checklist #1/#2).
|
||||
## Optional follow-ups
|
||||
- Live-test the Date Match push (`notifyOnDateMatch`) end-to-end (both partners "love" a date idea → "It's a match!").
|
||||
- Distribute the refreshed APK: `Closer-v0.1.0-debug-2026-06-24.apk`.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import app.closer.BuildConfig
|
|||
import app.closer.core.navigation.AppNavigation
|
||||
import app.closer.core.notifications.TokenRegistrar
|
||||
import app.closer.domain.model.AuthState
|
||||
import app.closer.notifications.PartnerNotificationPayload
|
||||
import app.closer.notifications.PartnerNotificationType
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -55,10 +57,15 @@ class MainActivity : AppCompatActivity() {
|
|||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
// Route to navigate to after a notification tap (set from the launch intent). Backed by state
|
||||
// so a tap while the app is already running (onNewIntent) also re-triggers navigation.
|
||||
private val pendingDeepLink = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
maybeRequestNotificationPermission()
|
||||
registerFcmToken()
|
||||
pendingDeepLink.value = deepLinkRouteFromIntent(intent)
|
||||
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
|
||||
setContent {
|
||||
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
|
||||
|
|
@ -91,7 +98,10 @@ class MainActivity : AppCompatActivity() {
|
|||
onUnlocked = { sessionVerified = true }
|
||||
)
|
||||
} else {
|
||||
AppNavigation()
|
||||
AppNavigation(
|
||||
pendingDeepLink = pendingDeepLink.value,
|
||||
onDeepLinkConsumed = { pendingDeepLink.value = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +111,28 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
deepLinkRouteFromIntent(intent)?.let { pendingDeepLink.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a navigation route from a notification tap. When the app is backgrounded/closed the
|
||||
* OS shows the FCM `notification` block and, on tap, launches us with the message `data` as
|
||||
* plain intent extras (no deep-link Uri) — so we rebuild the route here. A real deep-link Uri
|
||||
* (from our own foreground-posted PendingIntent) is left for the NavHost to handle.
|
||||
*/
|
||||
private fun deepLinkRouteFromIntent(intent: Intent?): String? {
|
||||
intent ?: return null
|
||||
if (intent.data != null) return null
|
||||
val type = intent.getStringExtra("type") ?: return null
|
||||
val coupleId = intent.getStringExtra("couple_id") ?: ""
|
||||
val payload = PartnerNotificationPayload(
|
||||
questionId = intent.getStringExtra("question_id"),
|
||||
gameSessionId = intent.getStringExtra("game_session_id"),
|
||||
capsuleId = intent.getStringExtra("capsule_id"),
|
||||
challengeId = intent.getStringExtra("challenge_id"),
|
||||
avatarUrl = intent.getStringExtra("sender_avatar_url")
|
||||
)
|
||||
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
|
|||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import app.closer.ui.auth.ForgotPasswordScreen
|
||||
import app.closer.ui.answers.AnswerHistoryScreen
|
||||
import app.closer.ui.answers.AnswerRevealScreen
|
||||
|
|
@ -87,7 +88,9 @@ import app.closer.ui.games.WaitingForPartnerScreen
|
|||
@Composable
|
||||
fun AppNavigation(
|
||||
modifier: Modifier = Modifier,
|
||||
startDestination: String = AppRoute.ONBOARDING
|
||||
startDestination: String = AppRoute.ONBOARDING,
|
||||
pendingDeepLink: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
|
@ -130,6 +133,22 @@ fun AppNavigation(
|
|||
}
|
||||
}
|
||||
|
||||
// A partner/chat notification was tapped while the app was backgrounded or closed. The OS
|
||||
// delivers the FCM payload as intent extras (not a deep-link Uri), so we resolve a route in
|
||||
// MainActivity and navigate here — but only once the user is past onboarding (authenticated +
|
||||
// on the main graph), otherwise the destination can't load and the tap appears to do nothing.
|
||||
LaunchedEffect(pendingDeepLink, currentRoute) {
|
||||
val link = pendingDeepLink ?: return@LaunchedEffect
|
||||
// Wait until the user has actually settled on Home (authenticated + onboarding finished).
|
||||
// Navigating during the onboarding→home transition races its popUpTo, which discards the
|
||||
// destination — the symptom of "the app opens but the message never loads".
|
||||
if (currentRoute == AppRoute.HOME) {
|
||||
kotlinx.coroutines.delay(350)
|
||||
navigateRoute(link)
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxSize()
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -32,4 +32,39 @@ class FirebaseStorageDataSource @Inject constructor(
|
|||
.addOnSuccessListener { cont.resume(it.toString()) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads already-encrypted chat-media bytes under the author's own storage path (mirrors the
|
||||
* profile-photo ownership model) and returns the tokenized download URL. The bytes are
|
||||
* ciphertext, so Storage never holds anything readable.
|
||||
*/
|
||||
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
|
||||
val metadata = StorageMetadata.Builder()
|
||||
.setContentType("application/octet-stream")
|
||||
.build()
|
||||
ref.putBytes(encryptedBytes, metadata)
|
||||
.continueWithTask { ref.downloadUrl }
|
||||
.addOnSuccessListener { cont.resume(it.toString()) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the raw (still-encrypted) bytes for a media message over HTTP using the tokenized
|
||||
* download URL, so the partner can read the author's object (the URL token authorizes it,
|
||||
* bypassing the owner-scoped Storage rule — same model as profile photos).
|
||||
*/
|
||||
suspend fun downloadBytes(downloadUrl: String): ByteArray =
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val connection = (java.net.URL(downloadUrl).openConnection() as java.net.HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
}
|
||||
try {
|
||||
connection.inputStream.use { it.readBytes() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
|||
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
|
||||
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
||||
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||
private val answerCommitment: AnswerCommitment
|
||||
private val answerCommitment: AnswerCommitment,
|
||||
private val storageDataSource: FirebaseStorageDataSource
|
||||
) {
|
||||
|
||||
private fun threadsRef(coupleId: String) =
|
||||
|
|
@ -164,12 +165,41 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
|||
.add(
|
||||
mapOf(
|
||||
"authorUserId" to message.userId,
|
||||
"type" to "text",
|
||||
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
|
||||
"createdAt" to FieldValue.serverTimestamp()
|
||||
)
|
||||
).refAwait()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an image message: the bytes are encrypted with the couple key on-device, the ciphertext
|
||||
* is uploaded to Storage, and only the (encrypted) media's URL is stored in Firestore.
|
||||
*/
|
||||
suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray) {
|
||||
val aead = encryptionManager.requireAead(coupleId)
|
||||
val encrypted = aead.encrypt(imageBytes, coupleId.toByteArray(Charsets.UTF_8))
|
||||
val url = storageDataSource.uploadEncryptedMedia(userId, encrypted)
|
||||
threadsRef(coupleId)
|
||||
.document(threadId)
|
||||
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
||||
.add(
|
||||
mapOf(
|
||||
"authorUserId" to userId,
|
||||
"type" to "image",
|
||||
"mediaUrl" to url,
|
||||
"createdAt" to FieldValue.serverTimestamp()
|
||||
)
|
||||
).refAwait()
|
||||
}
|
||||
|
||||
/** Downloads + decrypts an image message's bytes for display (couple key, on-device). */
|
||||
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
|
||||
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
|
||||
return runCatching { aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
|
||||
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow {
|
||||
val listener = threadsRef(coupleId)
|
||||
.document(threadId)
|
||||
|
|
@ -272,10 +302,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
|||
coupleId: String
|
||||
): QuestionMessage? {
|
||||
val userId = getString("authorUserId") ?: return null
|
||||
val type = getString("type") ?: "text"
|
||||
return QuestionMessage(
|
||||
id = id,
|
||||
userId = userId,
|
||||
text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "",
|
||||
type = type,
|
||||
mediaUrl = getString("mediaUrl") ?: "",
|
||||
text = if (type == "image") "" else (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: ""),
|
||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ class QuestionThreadRepositoryImpl @Inject constructor(
|
|||
override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) =
|
||||
dataSource.sendMessage(coupleId, threadId, message)
|
||||
|
||||
override suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray) =
|
||||
dataSource.sendImageMessage(coupleId, threadId, userId, imageBytes)
|
||||
|
||||
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
||||
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
||||
|
||||
override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> =
|
||||
dataSource.observeMessages(coupleId, threadId)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,5 +4,11 @@ data class QuestionMessage(
|
|||
val id: String = "",
|
||||
val userId: String = "",
|
||||
val text: String = "",
|
||||
/** "text" or "image". */
|
||||
val type: String = "text",
|
||||
/** Download URL of the ENCRYPTED image bytes in Storage (empty for text messages). */
|
||||
val mediaUrl: String = "",
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
) {
|
||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ interface QuestionThreadRepository {
|
|||
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer)
|
||||
fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>>
|
||||
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage)
|
||||
suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray)
|
||||
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
||||
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>>
|
||||
suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction)
|
||||
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ import javax.inject.Singleton
|
|||
* notification and this monitor isn't consulted — which is the desired behaviour.
|
||||
*/
|
||||
@Singleton
|
||||
class ActiveThreadMonitor @Inject constructor() {
|
||||
class ActiveThreadMonitor @Inject constructor(
|
||||
private val messageBubbleController: MessageBubbleController
|
||||
) {
|
||||
@Volatile
|
||||
var activeQuestionId: String? = null
|
||||
private set
|
||||
|
||||
fun enter(questionId: String) {
|
||||
if (questionId.isNotBlank()) activeQuestionId = questionId
|
||||
if (questionId.isNotBlank()) {
|
||||
activeQuestionId = questionId
|
||||
// Opening the conversation counts as reading it — clear any chat bubble for it.
|
||||
messageBubbleController.dismissFor(questionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun leave(questionId: String) {
|
||||
|
|
|
|||
|
|
@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() {
|
|||
fun dismiss() {
|
||||
_bubble.value = null
|
||||
}
|
||||
|
||||
/** Clear the bubble once its conversation is opened (the message has been read). */
|
||||
fun dismissFor(questionId: String) {
|
||||
_bubble.update { current -> if (current?.questionId == questionId) null else current }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,15 @@ import java.util.concurrent.TimeUnit
|
|||
/**
|
||||
* Persisted rate limiter for partner-trigger and reminder notifications.
|
||||
*
|
||||
* Limits:
|
||||
* - 2 partner-trigger notifications per day
|
||||
* - 1 reminder notification per day
|
||||
* - 4 total notifications per week
|
||||
* Limits (sized for a couples app where partner activity is the core loop — the old 2/day +
|
||||
* 4/week caps suppressed legitimate game start/finish and partner-action notifications after a
|
||||
* single game; these are anti-runaway ceilings, not gentle-nudge throttles):
|
||||
* - 20 partner-trigger notifications per day
|
||||
* - 1 reminder notification per day (proactive nudges stay gentle)
|
||||
* - 100 total notifications per week
|
||||
*
|
||||
* Note: chat messages are NOT throttled here — foreground messages show the in-app bubble and
|
||||
* backgrounded ones are displayed by the OS from the FCM notification block, both bypassing this.
|
||||
*
|
||||
* Counts are stored in [SharedPreferences] and reset when a new day or week starts.
|
||||
*/
|
||||
|
|
@ -30,9 +35,9 @@ class NotificationRateLimiter(context: Context) {
|
|||
private const val KEY_REMINDER_COUNT = "reminder_count"
|
||||
private const val KEY_TOTAL_COUNT = "total_count"
|
||||
|
||||
const val MAX_PARTNER_PER_DAY = 2
|
||||
const val MAX_PARTNER_PER_DAY = 20
|
||||
const val MAX_REMINDER_PER_DAY = 1
|
||||
const val MAX_TOTAL_PER_WEEK = 4
|
||||
const val MAX_TOTAL_PER_WEEK = 100
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController
|
|||
import app.closer.ui.theme.CloserPalette
|
||||
import coil.compose.AsyncImage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -80,10 +80,33 @@ fun MessageBubbleOverlay(
|
|||
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
|
||||
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
|
||||
|
||||
// Auto-dismiss if the user neither opens nor moves it for a while.
|
||||
LaunchedEffect(current.questionId, current.count) {
|
||||
delay(12_000)
|
||||
viewModel.dismiss()
|
||||
// The bubble persists until the message is read — opening the conversation clears it (via
|
||||
// ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
|
||||
var dragging by remember(current.questionId) { mutableStateOf(false) }
|
||||
var nearDismiss by remember(current.questionId) { mutableStateOf(false) }
|
||||
val dismissZonePx = with(density) { 150.dp.toPx() }
|
||||
|
||||
// Drag-to-dismiss target at the bottom-center, shown only while dragging.
|
||||
if (dragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp)
|
||||
.size(if (nearDismiss) 66.dp else 54.dp)
|
||||
.shadow(6.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (nearDismiss) CloserPalette.PinkAccentDeep else Color.Black.copy(alpha = 0.45f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
@ -96,14 +119,26 @@ fun MessageBubbleOverlay(
|
|||
.border(2.5.dp, Color.White, CircleShape)
|
||||
.pointerInput(current.questionId) {
|
||||
detectDragGestures(
|
||||
onDragStart = { dragging = true },
|
||||
onDrag = { change, drag ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
|
||||
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
|
||||
nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx)
|
||||
},
|
||||
onDragEnd = {
|
||||
// Snap to whichever side is closer (chat-head behaviour).
|
||||
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
||||
dragging = false
|
||||
if (nearDismiss) {
|
||||
viewModel.dismiss()
|
||||
} else {
|
||||
// Snap to whichever side is closer (chat-head behaviour).
|
||||
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
||||
}
|
||||
nearDismiss = false
|
||||
},
|
||||
onDragCancel = {
|
||||
dragging = false
|
||||
nearDismiss = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -77,6 +80,7 @@ data class PartnerHomeUiState(
|
|||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val partnerName: String? = null,
|
||||
val partnerPhotoUrl: String? = null,
|
||||
val streakCount: Int = 0,
|
||||
val hasPartnerAnsweredToday: Boolean = false,
|
||||
val coupleId: String? = null,
|
||||
|
|
@ -119,11 +123,13 @@ class PartnerHomeViewModel @Inject constructor(
|
|||
return@launch
|
||||
}
|
||||
val partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
val partnerName = partnerId?.let { pid ->
|
||||
runCatching { userRepository.getUser(pid)?.displayName }
|
||||
.onFailure { Log.w(TAG, "Could not load partner name", it) }
|
||||
val partner = partnerId?.let { pid ->
|
||||
runCatching { userRepository.getUser(pid) }
|
||||
.onFailure { Log.w(TAG, "Could not load partner", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
val partnerName = partner?.displayName
|
||||
val partnerPhotoUrl = partner?.photoUrl
|
||||
val dailyAssignment = runCatching {
|
||||
answerDataSource.getDailyQuestionAssignment(couple.id)
|
||||
}.getOrNull()
|
||||
|
|
@ -132,6 +138,7 @@ class PartnerHomeViewModel @Inject constructor(
|
|||
it.copy(
|
||||
isLoading = false,
|
||||
partnerName = partnerName,
|
||||
partnerPhotoUrl = partnerPhotoUrl,
|
||||
streakCount = couple.streakCount,
|
||||
coupleId = couple.id,
|
||||
dailyQuestionId = dailyAssignment?.questionId,
|
||||
|
|
@ -269,7 +276,7 @@ private fun PartnerHomeContent(
|
|||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
PartnerIdentityCard(name = state.partnerName, streakCount = state.streakCount)
|
||||
PartnerIdentityCard(name = state.partnerName, photoUrl = state.partnerPhotoUrl, streakCount = state.streakCount)
|
||||
|
||||
PartnerActivityCard(
|
||||
partnerName = state.partnerName,
|
||||
|
|
@ -299,6 +306,7 @@ private fun PartnerHomeContent(
|
|||
@Composable
|
||||
private fun PartnerIdentityCard(
|
||||
name: String?,
|
||||
photoUrl: String?,
|
||||
streakCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -313,20 +321,31 @@ private fun PartnerIdentityCard(
|
|||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = CloserPalette.PurpleDeep.copy(alpha = 0.14f),
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = (name?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp
|
||||
),
|
||||
color = CloserPalette.PurpleDeep
|
||||
)
|
||||
if (!photoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = photoUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = CloserPalette.PurpleDeep.copy(alpha = 0.14f),
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = (name?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp
|
||||
),
|
||||
color = CloserPalette.PurpleDeep
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
|
|
@ -250,7 +252,6 @@ private fun SubmittedAnswerCard(
|
|||
question: Question,
|
||||
state: LocalQuestionUiState
|
||||
) {
|
||||
val badge = if (state.isRevealed) "Revealed" else "OK"
|
||||
val label = when {
|
||||
state.isRevealed -> "Answer revealed"
|
||||
!state.partnerHasAnswered -> "Private answer saved — waiting for partner"
|
||||
|
|
@ -274,11 +275,11 @@ private fun SubmittedAnswerCard(
|
|||
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold
|
||||
Icon(
|
||||
imageVector = if (state.isRevealed) Icons.Filled.Visibility else Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
|
|
|
|||
|
|
@ -238,10 +238,13 @@ private fun RevealedPhase(
|
|||
QuestionDiscussionThread(
|
||||
messages = state.messages,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
partnerPhotoUrl = state.partnerPhotoUrl,
|
||||
messageInput = state.messageInput,
|
||||
onMessageInputChanged = viewModel::updateMessageInput,
|
||||
onSendMessage = viewModel::sendMessage,
|
||||
isRevealed = true
|
||||
isRevealed = true,
|
||||
onSendImage = viewModel::sendImage,
|
||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||
)
|
||||
|
||||
// Navigation out of the thread
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ data class QuestionThreadUiState(
|
|||
val phase: QuestionPhase = QuestionPhase.INPUT,
|
||||
val myAnswer: QuestionAnswer? = null,
|
||||
val partnerAnswer: QuestionAnswer? = null,
|
||||
val partnerPhotoUrl: String? = null,
|
||||
val messages: List<QuestionMessage> = emptyList(),
|
||||
val reactions: List<QuestionReaction> = emptyList(),
|
||||
val pendingWrittenText: String = "",
|
||||
|
|
@ -49,6 +50,9 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
private val questionDao: QuestionDao,
|
||||
private val sealedRevealManager: SealedRevealManager,
|
||||
private val activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
|
||||
private val localAnswerRepository: app.closer.domain.repository.LocalAnswerRepository,
|
||||
private val userRepository: app.closer.domain.repository.UserRepository,
|
||||
private val coupleRepository: app.closer.domain.repository.CoupleRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -59,6 +63,10 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
// Released-once guard for our thread reveal key.
|
||||
private var threadKeyReleased = false
|
||||
|
||||
// True when the matching daily question was already answered + revealed in the daily flow, so
|
||||
// the discussion (chat) should open directly here rather than asking the user to re-answer.
|
||||
private var dailyRevealed = false
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
QuestionThreadUiState(
|
||||
previousQuestionId = savedStateHandle["prevId"],
|
||||
|
|
@ -90,9 +98,26 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
}
|
||||
_uiState.update { it.copy(question = question, isLoading = false) }
|
||||
|
||||
// If this question's daily reveal is already complete, skip the answer phase and
|
||||
// open the chat directly — the answers were already given/seen in the daily flow.
|
||||
dailyRevealed = runCatching {
|
||||
localAnswerRepository.getAnswer(questionId)?.isRevealed == true
|
||||
}.getOrDefault(false)
|
||||
|
||||
val threadId = repository.findOrCreateThreadId(coupleId, questionId, question.category, currentUserId)
|
||||
_uiState.update { it.copy(threadId = threadId) }
|
||||
|
||||
// Load both partners' avatars so each chat message can show its sender's photo,
|
||||
// like a modern messaging thread.
|
||||
launch {
|
||||
val couple = runCatching { coupleRepository.getCoupleForUser(currentUserId) }.getOrNull()
|
||||
val partnerId = couple?.userIds?.firstOrNull { it != currentUserId }
|
||||
val partnerPhoto = partnerId?.let {
|
||||
runCatching { userRepository.getUser(it)?.photoUrl }.getOrNull()
|
||||
}
|
||||
_uiState.update { it.copy(partnerPhotoUrl = partnerPhoto) }
|
||||
}
|
||||
|
||||
launch {
|
||||
repository.observeAnswers(coupleId, threadId).collect { answers ->
|
||||
handleAnswers(threadId, answers)
|
||||
|
|
@ -124,16 +149,9 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
val mySealed = answers.find { it.userId == currentUserId }
|
||||
val partnerSealed = answers.find { it.userId != currentUserId }
|
||||
when {
|
||||
mySealed == null ->
|
||||
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
|
||||
|
||||
partnerSealed == null ->
|
||||
_uiState.update {
|
||||
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Both answered — release our key so the partner can decrypt us, then decrypt theirs.
|
||||
// Both answered IN this thread (e.g. a question pack answered here) — native reveal.
|
||||
mySealed != null && partnerSealed != null -> {
|
||||
// Release our key so the partner can decrypt us, then decrypt theirs.
|
||||
releaseThreadKeyOnce(threadId, partnerSealed.userId)
|
||||
val mine = decryptOwn(threadId, mySealed)
|
||||
val partner = decryptPartner(threadId, partnerSealed)
|
||||
|
|
@ -144,6 +162,19 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) }
|
||||
}
|
||||
}
|
||||
|
||||
// Daily question already revealed in the daily flow → open the chat directly so the
|
||||
// couple can message about it (no re-answering needed here).
|
||||
dailyRevealed ->
|
||||
_uiState.update { it.copy(phase = QuestionPhase.REVEALED, myAnswer = null, partnerAnswer = null) }
|
||||
|
||||
mySealed != null ->
|
||||
_uiState.update {
|
||||
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
|
||||
}
|
||||
|
||||
else ->
|
||||
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +325,21 @@ class QuestionThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun sendImage(imageBytes: ByteArray) {
|
||||
val state = _uiState.value
|
||||
val threadId = state.threadId ?: return
|
||||
if (state.phase != QuestionPhase.REVEALED) return
|
||||
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.sendImageMessage(coupleId, threadId, currentUserId, imageBytes) }
|
||||
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Couldn't send the photo.") } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads + decrypts an image message's bytes for display (called lazily by the UI). */
|
||||
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
|
||||
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
||||
|
||||
// ─── Reactions ───────────────────────────────────────────────────────────────
|
||||
|
||||
fun addReaction(targetUserId: String, emoji: String) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
package app.closer.ui.questions.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -8,10 +18,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -21,12 +37,30 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import app.closer.domain.model.QuestionMessage
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun QuestionDiscussionThread(
|
||||
|
|
@ -36,7 +70,10 @@ fun QuestionDiscussionThread(
|
|||
onMessageInputChanged: (String) -> Unit,
|
||||
onSendMessage: () -> Unit,
|
||||
isRevealed: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
partnerPhotoUrl: String? = null,
|
||||
onSendImage: (ByteArray) -> Unit = {},
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray? = { null }
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(
|
||||
|
|
@ -71,10 +108,18 @@ fun QuestionDiscussionThread(
|
|||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
messages.forEach { message ->
|
||||
messages.forEachIndexed { index, message ->
|
||||
val isMe = message.userId == currentUserId
|
||||
// Show the sender's avatar only on the last message of a consecutive run, like
|
||||
// modern chat apps — the others reserve the space so bubbles stay aligned.
|
||||
val showAvatar = index == messages.lastIndex ||
|
||||
messages[index + 1].userId != message.userId
|
||||
DiscussionMessageBubble(
|
||||
message = message,
|
||||
isCurrentUser = message.userId == currentUserId
|
||||
isCurrentUser = isMe,
|
||||
partnerAvatarUrl = partnerPhotoUrl,
|
||||
showAvatar = showAvatar,
|
||||
loadDecryptedMedia = loadDecryptedMedia
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +129,8 @@ fun QuestionDiscussionThread(
|
|||
DiscussionInputBar(
|
||||
value = messageInput,
|
||||
onValueChange = onMessageInputChanged,
|
||||
onSend = onSendMessage
|
||||
onSend = onSendMessage,
|
||||
onSendImage = onSendImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +138,10 @@ fun QuestionDiscussionThread(
|
|||
@Composable
|
||||
private fun DiscussionMessageBubble(
|
||||
message: QuestionMessage,
|
||||
isCurrentUser: Boolean
|
||||
isCurrentUser: Boolean,
|
||||
partnerAvatarUrl: String?,
|
||||
showAvatar: Boolean,
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||
) {
|
||||
val bubbleShape = if (isCurrentUser) {
|
||||
RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||
|
|
@ -102,26 +151,120 @@ private fun DiscussionMessageBubble(
|
|||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start
|
||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = if (isCurrentUser)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 260.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
// Messenger style: only the partner's avatar is shown (on the left). Our own messages are
|
||||
// just bubbles on the right with no avatar.
|
||||
if (!isCurrentUser) {
|
||||
MessageAvatar(partnerAvatarUrl, visible = showAvatar)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
if (message.isImage) {
|
||||
EncryptedChatImage(
|
||||
mediaUrl = message.mediaUrl,
|
||||
shape = bubbleShape,
|
||||
loadDecryptedMedia = loadDecryptedMedia
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = if (isCurrentUser)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
maxLines = 10,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 240.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isCurrentUser)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
maxLines = 10,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads the encrypted image bytes, decrypts them on-device, and renders the photo. */
|
||||
@Composable
|
||||
private fun EncryptedChatImage(
|
||||
mediaUrl: String,
|
||||
shape: androidx.compose.ui.graphics.Shape,
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||
) {
|
||||
val image by produceState<ImageBitmap?>(initialValue = null, mediaUrl) {
|
||||
val bytes = loadDecryptedMedia(mediaUrl)
|
||||
value = bytes?.let {
|
||||
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 220.dp)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val bmp = image
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = "Photo",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.widthIn(max = 220.dp)
|
||||
)
|
||||
} else {
|
||||
// Decrypting / downloading — keep a square placeholder with a spinner.
|
||||
Box(
|
||||
modifier = Modifier.size(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageAvatar(url: String?, visible: Boolean) {
|
||||
val size = 28.dp
|
||||
if (!visible) {
|
||||
// Reserve the space so consecutive bubbles from the same sender stay aligned.
|
||||
Spacer(modifier = Modifier.size(size))
|
||||
return
|
||||
}
|
||||
if (!url.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(size).clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -131,13 +274,83 @@ private fun DiscussionMessageBubble(
|
|||
private fun DiscussionInputBar(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
onSend: () -> Unit,
|
||||
onSendImage: (ByteArray) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Read the picked/captured image bytes off the main thread, then hand them up to be encrypted
|
||||
// and sent.
|
||||
fun readAndSend(uri: Uri) {
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
}
|
||||
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery — images only (modern Photo Picker).
|
||||
val galleryLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri: Uri? -> uri?.let { readAndSend(it) } }
|
||||
|
||||
// Camera capture into a temp file via FileProvider.
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
|
||||
|
||||
fun launchCamera() {
|
||||
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
pendingCameraUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted: Boolean -> if (granted) launchCamera() }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
galleryLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = "Send a photo",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
launchCamera()
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PhotoCamera,
|
||||
contentDescription = "Take a photo",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="profile_photos" path="photos/" />
|
||||
<cache-path name="chat_captures" path="." />
|
||||
</paths>
|
||||
|
|
|
|||
|
|
@ -376,11 +376,20 @@ service cloud.firestore {
|
|||
// Discussion messages: any couple member can read, but only the author can write/update/delete
|
||||
match /messages/{messageId} {
|
||||
allow read: if isCouplesMember(coupleId);
|
||||
// Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl`
|
||||
// pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted).
|
||||
allow create: if isCouplesMember(coupleId)
|
||||
&& coupleEncryptionEnabled(coupleId)
|
||||
&& request.resource.data.authorUserId == request.auth.uid
|
||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt'])
|
||||
&& isCiphertext(request.resource.data.text);
|
||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl'])
|
||||
&& (
|
||||
(request.resource.data.get('type', 'text') == 'image'
|
||||
&& request.resource.data.mediaUrl is string
|
||||
&& request.resource.data.mediaUrl.size() > 0)
|
||||
||
|
||||
(request.resource.data.get('type', 'text') == 'text'
|
||||
&& isCiphertext(request.resource.data.text))
|
||||
);
|
||||
allow update: if isCouplesMember(coupleId)
|
||||
&& coupleEncryptionEnabled(coupleId)
|
||||
&& resource.data.authorUserId == request.auth.uid
|
||||
|
|
|
|||
|
|
@ -76,14 +76,19 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
res.status(400).json({ error: 'malformed_payload' });
|
||||
return;
|
||||
}
|
||||
// Acknowledge immediately to avoid RevenueCat retries.
|
||||
res.status(200).json({ received: true });
|
||||
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
|
||||
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
|
||||
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
|
||||
// so returning 500 on failure recovers the event safely.
|
||||
try {
|
||||
await (0, entitlementLogic_1.applyEntitlementEvent)(event);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[revenueCatWebhook] entitlement sync failed:', err);
|
||||
res.status(500).json({ error: 'processing_failed' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ received: true });
|
||||
});
|
||||
class ConfigError extends Error {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAgC;AAChC,8DAA+C;AAC/C,yDAA4E;AAE5E;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;;IAC5E,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAA;QACrD,OAAM;IACR,CAAC;IAED,IAAI,CAAC;QACH,eAAe,CAAC,GAAG,CAAC,CAAA;IACtB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,YAAY,WAAW,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,0CAA0C,EAAE,GAAG,CAAC,OAAO,CAAC,CAAA;YACtE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC,CAAA;YACjD,OAAM;QACR,CAAC;QACD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;QAC/C,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,MAAA,GAAG,CAAC,IAAI,0CAAE,KAAqC,CAAA;IAC7D,IAAI,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAChD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAA;QACpD,OAAM;IACR,CAAC;IAED,sFAAsF;IACtF,0FAA0F;IAC1F,0FAA0F;IAC1F,yDAAyD;IACzD,IAAI,CAAC;QACH,MAAM,IAAA,wCAAqB,EAAC,KAAK,CAAC,CAAA;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,8CAA8C,EAAE,GAAG,CAAC,CAAA;QAClE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAA;QACpD,OAAM;IACR,CAAC;IAED,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;AAC1C,CAAC,CAAC,CAAA;AAEF,MAAM,WAAY,SAAQ,KAAK;CAAG;AAClC,MAAM,SAAU,SAAQ,KAAK;CAAG;AAEhC;;;;;;GAMG;AACH,SAAS,eAAe,CAAC,GAA4B;IACnD,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAA;IACrD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,WAAW,CAAC,wDAAwD,CAAC,CAAA;IACjF,CAAC;IAED,0EAA0E;IAC1E,MAAM,OAAO,GAAI,GAAuC,CAAC,OAAO,CAAA;IAChE,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,SAAS,CAAC,sBAAsB,CAAC,CAAA;IAC7C,CAAC;IAED,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC,CAAA;IAC5C,MAAM,SAAS,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACrE,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAA;IACnD,CAAC;IAED,IAAI,SAA2B,CAAA;IAC/B,IAAI,CAAC;QACH,SAAS,GAAG,MAAM,CAAC,eAAe,CAAC;YACjC,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC;YACtC,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,MAAM;SACb,CAAC,CAAA;IACJ,CAAC;IAAC,WAAM,CAAC;QACP,MAAM,IAAI,WAAW,CACnB,2FAA2F,CAC5F,CAAA;IACH,CAAC;IAED,IAAI,SAAiB,CAAA;IACrB,IAAI,CAAC;QACH,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;IAC9C,CAAC;IAAC,WAAM,CAAC;QACP,MAAM,IAAI,SAAS,CAAC,iCAAiC,CAAC,CAAA;IACxD,CAAC;IAED,IAAI,KAAc,CAAA;IAClB,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC,CAAA;IAC5D,CAAC;IAAC,WAAM,CAAC;QACP,MAAM,IAAI,SAAS,CAAC,+BAA+B,CAAC,CAAA;IACtD,CAAC;IAED,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,SAAS,CAAC,oBAAoB,CAAC,CAAA;IAC3C,CAAC;AACH,CAAC"}
|
||||
|
|
@ -133,15 +133,13 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
}
|
||||
const coupleId = db.collection('couples').doc().id;
|
||||
const coupleRef = db.collection('couples').doc(coupleId);
|
||||
// Derive encryption version from E2EE field presence.
|
||||
// encryptionVersion must stay in sync with EncryptionVersion.kt:
|
||||
// 0 = plaintext (no couple key; iOS MVP path)
|
||||
// 1 = legacy migration (mixed)
|
||||
// 2 = strict E2EE (all new Android couples)
|
||||
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state
|
||||
// where the client expects a key that does not exist.
|
||||
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null;
|
||||
const encryptionVersion = hasE2EE ? 2 : 0;
|
||||
// Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
|
||||
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
|
||||
// broken plaintext couple the client can't use.
|
||||
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'Invite is missing encryption material. Ask your partner to create a new invite.');
|
||||
}
|
||||
const encryptionVersion = 2;
|
||||
const batch = db.batch();
|
||||
batch.set(coupleRef, {
|
||||
id: coupleId,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -50,13 +50,11 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||
* - kdfSalt: base64 KDF salt
|
||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
||||
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
||||
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
||||
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
|
||||
* code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
|
||||
*
|
||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||
* supply these fields. Android always supplies them.
|
||||
* Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
|
||||
* all required. There is no plaintext-couple path.
|
||||
*
|
||||
* Response: { code: string, expiresAt: Timestamp }
|
||||
*
|
||||
|
|
@ -115,11 +113,19 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
|
||||
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
|
||||
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
|
||||
// E2EE fields must be supplied together or omitted together.
|
||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
|
||||
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
|
||||
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
|
||||
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
|
||||
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
|
||||
if (!clientCode) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
|
||||
}
|
||||
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style
|
||||
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
|
||||
// codes and anything that could be abused as the document id.
|
||||
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.');
|
||||
}
|
||||
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.');
|
||||
}
|
||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
|
||||
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -51,7 +51,7 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* partner notification, so we don't duplicate that here.
|
||||
*/
|
||||
exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
|
||||
var _a, _b, _c, _d;
|
||||
var _a, _b;
|
||||
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.');
|
||||
|
|
@ -67,23 +67,41 @@ exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
|
|||
return { success: true };
|
||||
}
|
||||
const coupleRef = db.collection('couples').doc(coupleId);
|
||||
const coupleDoc = await coupleRef.get();
|
||||
if (!coupleDoc.exists) {
|
||||
// Couple doc gone — just clear caller's field.
|
||||
await db.collection('users').doc(callerId).update({ coupleId: null });
|
||||
return { success: true };
|
||||
}
|
||||
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
|
||||
if (!userIds.includes(callerId)) {
|
||||
// Security review Batch 2: do the membership check, member-clearing, and couple-doc
|
||||
// delete in one transaction so two partners leaving concurrently can't clobber state.
|
||||
// Critically, only clear a member's coupleId if it STILL points at this couple — a
|
||||
// stale concurrent call must never wipe a coupleId set by a fresh re-pair.
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
var _a, _b, _c;
|
||||
const coupleSnap = await tx.get(coupleRef);
|
||||
if (!coupleSnap.exists) {
|
||||
const callerRef = db.collection('users').doc(callerId);
|
||||
const callerSnap = await tx.get(callerRef);
|
||||
if (((_a = callerSnap.data()) === null || _a === void 0 ? void 0 : _a.coupleId) === coupleId) {
|
||||
tx.update(callerRef, { coupleId: null });
|
||||
}
|
||||
return { membership: true };
|
||||
}
|
||||
const userIds = ((_c = (_b = coupleSnap.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
|
||||
if (!userIds.includes(callerId)) {
|
||||
return { membership: false };
|
||||
}
|
||||
// Reads must precede writes in a transaction: snapshot every member first.
|
||||
const memberSnaps = await Promise.all(userIds.map((uid) => tx.get(db.collection('users').doc(uid))));
|
||||
memberSnaps.forEach((snap, i) => {
|
||||
var _a;
|
||||
if (((_a = snap.data()) === null || _a === void 0 ? void 0 : _a.coupleId) === coupleId) {
|
||||
tx.update(db.collection('users').doc(userIds[i]), { coupleId: null });
|
||||
}
|
||||
});
|
||||
tx.delete(coupleRef);
|
||||
return { membership: true };
|
||||
});
|
||||
if (!result.membership) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.');
|
||||
}
|
||||
// Clear coupleId for all members atomically.
|
||||
const batch = db.batch();
|
||||
for (const uid of userIds) {
|
||||
batch.update(db.collection('users').doc(uid), { coupleId: null });
|
||||
}
|
||||
await batch.commit();
|
||||
// Recursively delete the couple document and every subcollection beneath it.
|
||||
// Couple doc is deleted in the transaction; sweep any subcollections left behind.
|
||||
// Idempotent if a concurrent caller already removed them.
|
||||
await db.recursiveDelete(coupleRef);
|
||||
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`);
|
||||
return { success: true };
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;;;;GAaG;AACU,QAAA,mBAAmB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,kCAAkC,CAAC,CAAA;IACjG,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,yCAAyC;QACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;IAEvC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,+CAA+C;QAC/C,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;QACrE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,mBAAmB,EAAE,8BAA8B,CAAC,CAAA;IAC3F,CAAC;IAED,6CAA6C;IAC7C,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,EAAE,CAAA;IACxB,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,KAAK,CAAC,MAAM,EAAE,CAAA;IAEpB,6EAA6E;IAC7E,MAAM,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAA;IAEnC,OAAO,CAAC,GAAG,CAAC,8BAA8B,QAAQ,gBAAgB,QAAQ,EAAE,CAAC,CAAA;IAC7E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAC,CAAA"}
|
||||
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -33,59 +33,33 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createDateMatchOnMutualLove = void 0;
|
||||
exports.notifyOnDateMatch = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const LOVE = 'love';
|
||||
/**
|
||||
* Creates a revealed date match when both partners have swiped LOVE on the
|
||||
* same date idea.
|
||||
* Fires the "It's a match!" notification when a date match is created.
|
||||
*
|
||||
* Trigger: couples/{coupleId}/date_swipes/{dateIdeaId} (onWrite)
|
||||
* Trigger: couples/{coupleId}/date_matches/{dateIdeaId} (onCreate)
|
||||
*
|
||||
* The `date_matches` collection is server-write-only — Firestore rules deny all
|
||||
* client writes (`allow create, update, delete: if false`). This trigger is
|
||||
* therefore the single source of truth for match creation. The client only
|
||||
* records swipes and observes `date_matches` for the result.
|
||||
* Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
|
||||
* Mutual-match detection now happens client-side (whichever partner records the
|
||||
* second LOVE writes the match marker, validated by Firestore rules). This trigger
|
||||
* only sends the push to both partners — it never reads swipe content.
|
||||
*
|
||||
* Idempotency: the match document id is the date idea id and creation runs in a
|
||||
* transaction, so repeated swipes on the same idea and concurrent invocations
|
||||
* never produce a duplicate match.
|
||||
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
|
||||
* (or a client retry) never double-send. The match doc id is the date idea id, so
|
||||
* the marker itself is already de-duplicated by the client transaction + rules.
|
||||
*/
|
||||
exports.createDateMatchOnMutualLove = functions.firestore
|
||||
.document('couples/{coupleId}/date_swipes/{dateIdeaId}')
|
||||
.onWrite(async (change, context) => {
|
||||
var _a, _b, _c;
|
||||
const after = change.after.data();
|
||||
if (!after)
|
||||
return; // swipe document was deleted
|
||||
const actions = ((_a = after.actions) !== null && _a !== void 0 ? _a : {});
|
||||
const lovedBy = Object.entries(actions)
|
||||
.filter(([, entry]) => (entry === null || entry === void 0 ? void 0 : entry.action) === LOVE)
|
||||
.map(([uid]) => uid)
|
||||
.sort();
|
||||
// A match needs both partners to have loved the same idea.
|
||||
if (lovedBy.length < 2)
|
||||
exports.notifyOnDateMatch = functions.firestore
|
||||
.document('couples/{coupleId}/date_matches/{dateIdeaId}')
|
||||
.onCreate(async (snap, context) => {
|
||||
var _a, _b;
|
||||
if (!snap.exists)
|
||||
return;
|
||||
const { coupleId, dateIdeaId } = context.params;
|
||||
const db = admin.firestore();
|
||||
const matchRef = db
|
||||
.collection('couples')
|
||||
.doc(coupleId)
|
||||
.collection('date_matches')
|
||||
.doc(dateIdeaId);
|
||||
await db.runTransaction(async (tx) => {
|
||||
const existing = await tx.get(matchRef);
|
||||
if (existing.exists)
|
||||
return;
|
||||
tx.set(matchRef, {
|
||||
dateIdeaId,
|
||||
matchedBy: lovedBy,
|
||||
revealedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
fcmNotified: false,
|
||||
});
|
||||
});
|
||||
// Atomically claim FCM send so concurrent trigger invocations don't double-send.
|
||||
const matchRef = snap.ref;
|
||||
// Atomically claim the FCM send so concurrent invocations don't double-send.
|
||||
const shouldSend = await db.runTransaction(async (tx) => {
|
||||
var _a;
|
||||
const doc = await tx.get(matchRef);
|
||||
|
|
@ -94,11 +68,11 @@ exports.createDateMatchOnMutualLove = functions.firestore
|
|||
tx.update(matchRef, { fcmNotified: true });
|
||||
return true;
|
||||
});
|
||||
if (shouldSend) {
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
|
||||
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)));
|
||||
}
|
||||
if (!shouldSend)
|
||||
return;
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
|
||||
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)));
|
||||
});
|
||||
async function notifyDateMatch(db, userId, coupleId, dateIdeaId) {
|
||||
await db.collection('users').doc(userId).collection('notification_queue').add({
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -45,7 +45,7 @@ const admin = __importStar(require("firebase-admin"));
|
|||
exports.onGameSessionUpdate = functions.firestore
|
||||
.document('couples/{coupleId}/sessions/{sessionId}')
|
||||
.onWrite(async (change, context) => {
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
||||
const { coupleId, sessionId } = context.params;
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
|
|
@ -75,47 +75,46 @@ exports.onGameSessionUpdate = functions.firestore
|
|||
const userB = await db.collection('users').doc(partnerB).get();
|
||||
const partnerAName = (_d = (_c = userA.data()) === null || _c === void 0 ? void 0 : _c.displayName) !== null && _d !== void 0 ? _d : 'Partner A';
|
||||
const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B';
|
||||
const avatarA = (_g = userA.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
|
||||
const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl;
|
||||
// Check if session was just created (status = "active")
|
||||
const previousData = (_g = change.before.data()) !== null && _g !== void 0 ? _g : {};
|
||||
const currentData = (_h = change.after.data()) !== null && _h !== void 0 ? _h : {};
|
||||
const wasInactive = ((_j = previousData.status) !== null && _j !== void 0 ? _j : '') !== 'active';
|
||||
const previousData = (_j = change.before.data()) !== null && _j !== void 0 ? _j : {};
|
||||
const currentData = (_k = change.after.data()) !== null && _k !== void 0 ? _k : {};
|
||||
const wasInactive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') !== 'active';
|
||||
const isActiveNow = currentData.status === 'active';
|
||||
if (wasInactive && isActiveNow) {
|
||||
// New session started - notify the other partner
|
||||
// New session started — notify the OTHER partner, naming the person who started it.
|
||||
const startedBy = currentData.startedByUserId;
|
||||
const gameType = (_k = currentData.gameType) !== null && _k !== void 0 ? _k : 'wheel';
|
||||
const partnerId = startedBy === partnerA ? partnerB : partnerA;
|
||||
const partnerName = startedBy === partnerA ? partnerBName : partnerAName;
|
||||
await notifyPartner(db, messaging, partnerId, partnerName, gameType, 'partner_started_game', `${partnerName} has started a game. Tap to join!`, coupleId);
|
||||
const gameType = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel';
|
||||
const recipientId = startedBy === partnerA ? partnerB : partnerA;
|
||||
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
|
||||
const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
|
||||
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar);
|
||||
return;
|
||||
}
|
||||
// Check if session was completed
|
||||
const wasActive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') === 'active';
|
||||
const wasActive = ((_o = previousData.status) !== null && _o !== void 0 ? _o : '') === 'active';
|
||||
const isCompletedNow = currentData.status === 'completed';
|
||||
if (wasActive && isCompletedNow) {
|
||||
const completedBy = currentData.startedByUserId;
|
||||
const partnerId = completedBy === partnerA ? partnerB : partnerA;
|
||||
const completingPartnerName = completedBy === partnerA ? partnerAName : partnerBName;
|
||||
const gt = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel';
|
||||
const partnerCompletedAt = currentData.partnerCompletedAt;
|
||||
if (partnerCompletedAt) {
|
||||
await notifyPartner(db, messaging, partnerA, partnerAName, gt, 'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!`, coupleId);
|
||||
await notifyPartner(db, messaging, partnerB, partnerBName, gt, 'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!`, coupleId);
|
||||
}
|
||||
else {
|
||||
await notifyPartner(db, messaging, partnerId, completingPartnerName, gt, 'partner_finished_game', `${completingPartnerName} has finished. Tap to continue playing!`, coupleId);
|
||||
}
|
||||
// The session is complete (both partners have answered) — the reveal is ready for each of
|
||||
// them, so notify BOTH, each naming the OTHER partner.
|
||||
const gt = (_p = currentData.gameType) !== null && _p !== void 0 ? _p : 'wheel';
|
||||
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB);
|
||||
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA);
|
||||
return;
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Send notification to partner via FCM and write to notification_queue.
|
||||
*/
|
||||
async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId) {
|
||||
async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl) {
|
||||
var _a;
|
||||
const title = notificationType === 'partner_finished_game'
|
||||
? `${partnerName} finished the game`
|
||||
: `${partnerName} is playing`;
|
||||
const notificationPayload = {
|
||||
type: notificationType,
|
||||
title: `${partnerName} is playing`,
|
||||
title,
|
||||
body: body,
|
||||
};
|
||||
// Write an in-app notification record for the partner
|
||||
|
|
@ -155,11 +154,9 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
|
|||
title: notificationPayload.title,
|
||||
body: notificationPayload.body,
|
||||
},
|
||||
data: {
|
||||
type: notificationPayload.type,
|
||||
couple_id: coupleId,
|
||||
game_type: gameType,
|
||||
},
|
||||
data: Object.assign({ type: notificationPayload.type, couple_id: coupleId, game_type: gameType }, (senderAvatarUrl && senderAvatarUrl.length > 0
|
||||
? { sender_avatar_url: senderAvatarUrl }
|
||||
: {})),
|
||||
};
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token }))));
|
||||
const failures = [];
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -33,8 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = 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
|
||||
|
|
@ -61,7 +60,7 @@ Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, g
|
|||
var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
|
||||
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
|
||||
var createDateMatch_1 = require("./dates/createDateMatch");
|
||||
Object.defineProperty(exports, "createDateMatchOnMutualLove", { enumerable: true, get: function () { return createDateMatch_1.createDateMatchOnMutualLove; } });
|
||||
Object.defineProperty(exports, "notifyOnDateMatch", { enumerable: true, get: function () { return createDateMatch_1.notifyOnDateMatch; } });
|
||||
var assignDailyQuestion_1 = require("./questions/assignDailyQuestion");
|
||||
Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } });
|
||||
Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });
|
||||
|
|
@ -85,11 +84,8 @@ var onUserDelete_1 = require("./users/onUserDelete");
|
|||
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
|
||||
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
|
||||
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } });
|
||||
/**
|
||||
* Basic health check callable.
|
||||
* Useful for verifying function deployment and firebase-tools wiring.
|
||||
*/
|
||||
exports.health = functions.https.onRequest((req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
|
||||
// was removed to shrink attack surface. Deployment can be verified via
|
||||
// `firebase functions:list`. If an uptime probe is ever needed, re-add it behind
|
||||
// auth / a shared secret rather than as an open endpoint.
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,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,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,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,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,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}
|
||||
{"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,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,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,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,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}
|
||||
|
|
@ -123,7 +123,18 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
|
|||
if (!userIds.includes(callerId)) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.');
|
||||
}
|
||||
const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : cstDateString();
|
||||
// Security review Batch 2: constrain the client-supplied date. Only today's CST date
|
||||
// may be assigned on demand — this blocks creating arbitrary past/future daily_question
|
||||
// docs and, combined with create()'s ALREADY_EXISTS guard, caps it to one per day
|
||||
// (effective rate limit; repeat calls return already-exists).
|
||||
const today = cstDateString();
|
||||
const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : today;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.');
|
||||
}
|
||||
if (date !== today) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
|
||||
}
|
||||
const questionId = await pickRandomQuestionId();
|
||||
if (!questionId) {
|
||||
throw new functions.https.HttpsError('internal', 'No active questions available.');
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -48,7 +48,7 @@ const admin = __importStar(require("firebase-admin"));
|
|||
exports.onAnswerWritten = functions.firestore
|
||||
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}')
|
||||
.onCreate(async (snap, context) => {
|
||||
var _a, _b, _c, _d;
|
||||
var _a, _b, _c, _d, _e;
|
||||
const { coupleId, date, userId } = context.params;
|
||||
const db = admin.firestore();
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||
|
|
@ -57,6 +57,13 @@ exports.onAnswerWritten = functions.firestore
|
|||
return;
|
||||
}
|
||||
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
|
||||
// Security review Batch 2: re-verify the writer actually belongs to this couple
|
||||
// before sending a cross-user notification. Firestore rules already enforce this,
|
||||
// but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
|
||||
if (!userIds.includes(userId)) {
|
||||
console.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`);
|
||||
return;
|
||||
}
|
||||
const partnerId = userIds.find((uid) => uid !== userId);
|
||||
if (!partnerId) {
|
||||
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`);
|
||||
|
|
@ -96,17 +103,17 @@ exports.onAnswerWritten = functions.firestore
|
|||
}
|
||||
const answerData = snap.data();
|
||||
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
|
||||
// Sender (the partner who just answered) avatar — used as the notification large icon.
|
||||
const senderDoc = await db.collection('users').doc(userId).get();
|
||||
const senderAvatar = (_e = senderDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
|
||||
const payload = {
|
||||
notification: {
|
||||
title: 'Your partner just answered!',
|
||||
body: "See what they shared for tonight's prompt.",
|
||||
},
|
||||
data: {
|
||||
type: 'partner_answered',
|
||||
couple_id: coupleId,
|
||||
question_id: questionId,
|
||||
date,
|
||||
},
|
||||
data: Object.assign({ type: 'partner_answered', couple_id: coupleId, question_id: questionId, date }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
|
||||
? { sender_avatar_url: senderAvatar }
|
||||
: {})),
|
||||
};
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token }))));
|
||||
const failures = [];
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -47,7 +47,7 @@ const admin = __importStar(require("firebase-admin"));
|
|||
exports.onMessageWritten = functions.firestore
|
||||
.document('couples/{coupleId}/question_threads/{threadId}/messages/{messageId}')
|
||||
.onCreate(async (snap, context) => {
|
||||
var _a, _b, _c, _d;
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
||||
const { coupleId, threadId, messageId } = context.params;
|
||||
const db = admin.firestore();
|
||||
const messageData = snap.data();
|
||||
|
|
@ -67,16 +67,23 @@ exports.onMessageWritten = functions.firestore
|
|||
console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`);
|
||||
return;
|
||||
}
|
||||
// The conversation deep link + the client's "am I already in this thread?" suppression both
|
||||
// key off questionId, so resolve it from the thread doc and pass it through.
|
||||
const threadDoc = await db
|
||||
.collection('couples').doc(coupleId)
|
||||
.collection('question_threads').doc(threadId)
|
||||
.get();
|
||||
const questionId = (_d = (_c = threadDoc.data()) === null || _c === void 0 ? void 0 : _c.questionId) !== null && _d !== void 0 ? _d : '';
|
||||
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
|
||||
// Respect the partner's notification preference (opt-out; default is enabled).
|
||||
const notifEnabled = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifChatMessage;
|
||||
const notifEnabled = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.notifChatMessage;
|
||||
if (notifEnabled === false) {
|
||||
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
|
||||
return;
|
||||
}
|
||||
const tokens = [];
|
||||
if (partnerUserDoc.exists) {
|
||||
const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken;
|
||||
const legacyToken = (_f = partnerUserDoc.data()) === null || _f === void 0 ? void 0 : _f.fcmToken;
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken);
|
||||
}
|
||||
|
|
@ -97,16 +104,17 @@ exports.onMessageWritten = functions.firestore
|
|||
console.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`);
|
||||
return;
|
||||
}
|
||||
// The recipient sees the message from the author (their partner), so surface the author's
|
||||
// photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face.
|
||||
const authorDoc = await db.collection('users').doc(authorId).get();
|
||||
const authorPhotoUrl = (_h = (_g = authorDoc.data()) === null || _g === void 0 ? void 0 : _g.photoUrl) !== null && _h !== void 0 ? _h : '';
|
||||
const authorName = (_k = (_j = authorDoc.data()) === null || _j === void 0 ? void 0 : _j.displayName) !== null && _k !== void 0 ? _k : '';
|
||||
const payload = {
|
||||
notification: {
|
||||
title: 'Your partner sent a message',
|
||||
title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
|
||||
body: 'Tap to read and reply.',
|
||||
},
|
||||
data: {
|
||||
type: 'chat_message',
|
||||
couple_id: coupleId,
|
||||
thread_id: threadId,
|
||||
},
|
||||
data: Object.assign(Object.assign({ type: 'chat_message', couple_id: coupleId, thread_id: threadId }, (questionId ? { question_id: questionId } : {})), (authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {})),
|
||||
};
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token }))));
|
||||
const failures = [];
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;GAOG;AACU,QAAA,gBAAgB,GAAG,SAAS,CAAC,SAAS;KAChD,QAAQ,CAAC,qEAAqE,CAAC;KAC/E,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;;IAChC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,MAIjD,CAAA;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,EAAsC,CAAA;IACnE,MAAM,QAAQ,GAAG,OAAO,WAAW,CAAC,YAAY,KAAK,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAA;IAC/F,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CAAC,IAAI,CAAC,iDAAiD,SAAS,EAAE,CAAC,CAAA;QAC1E,OAAM;IACR,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CAAC,6BAA6B,QAAQ,YAAY,CAAC,CAAA;QAC/D,OAAM;IACR,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAA;IACzD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,kDAAkD,QAAQ,EAAE,CAAC,CAAA;QAC1E,OAAM;IACR,CAAC;IAED,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAExE,+EAA+E;IAC/E,MAAM,YAAY,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,gBAAgB,CAAA;IAC5D,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;QAC3B,OAAO,CAAC,GAAG,CAAC,8BAA8B,SAAS,6BAA6B,CAAC,CAAA;QACjF,OAAM;IACR,CAAC;IAED,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,IAAI,cAAc,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QACnD,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;IAED,MAAM,aAAa,GAAG,MAAM,EAAE;SAC3B,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,WAAW,CAAC;SACvB,GAAG,EAAE,CAAA;IACR,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACjC,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,CAAC,gDAAgD,SAAS,EAAE,CAAC,CAAA;QACxE,OAAM;IACR,CAAC;IAED,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE;YACZ,KAAK,EAAE,6BAA6B;YACpC,IAAI,EAAE,wBAAwB;SAC/B;QACD,IAAI,EAAE;YACJ,IAAI,EAAE,cAAc;YACpB,SAAS,EAAE,QAAQ;YACnB,SAAS,EAAE,QAAQ;SACpB;KACF,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAAK,OAAO,KAAE,KAAK,GAA6B,CAAC,CACzE,CACF,CAAA;IAED,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;QACpC,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YACjC,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAC7D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,KAAK,CAAC,+CAA+C,EAAE,QAAQ,CAAC,CAAA;IAC1E,CAAC;IAED,OAAO,CAAC,GAAG,CACT,uCAAuC,SAAS,eAAe,QAAQ,cAAc,QAAQ,EAAE,CAChG,CAAA;AACH,CAAC,CAAC,CAAA"}
|
||||
{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -81,9 +81,15 @@ export const onMessageWritten = functions.firestore
|
|||
return
|
||||
}
|
||||
|
||||
// The recipient sees the message from the author (their partner), so surface the author's
|
||||
// photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face.
|
||||
const authorDoc = await db.collection('users').doc(authorId).get()
|
||||
const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? ''
|
||||
const authorName = (authorDoc.data()?.displayName as string | undefined) ?? ''
|
||||
|
||||
const payload: admin.messaging.MessagingPayload = {
|
||||
notification: {
|
||||
title: 'Your partner sent a message',
|
||||
title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
|
||||
body: 'Tap to read and reply.',
|
||||
},
|
||||
data: {
|
||||
|
|
@ -91,6 +97,7 @@ export const onMessageWritten = functions.firestore
|
|||
couple_id: coupleId,
|
||||
thread_id: threadId,
|
||||
...(questionId ? { question_id: questionId } : {}),
|
||||
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ service firebase.storage {
|
|||
allow read: if request.auth != null && request.auth.uid == uid;
|
||||
}
|
||||
|
||||
// Encrypted chat media: the author writes under their own path (already E2E-encrypted
|
||||
// ciphertext, so Storage never holds anything readable). The partner reads via the tokenized
|
||||
// download URL, which bypasses these rules — same model as profile photos. 15 MB cap.
|
||||
match /users/{uid}/chat_media/{file} {
|
||||
allow write: if request.auth != null
|
||||
&& request.auth.uid == uid
|
||||
&& request.resource.size < 15 * 1024 * 1024;
|
||||
allow read: if request.auth != null && request.auth.uid == uid;
|
||||
}
|
||||
|
||||
// Deny all other paths by default.
|
||||
match /{allPaths=**} {
|
||||
allow read, write: if false;
|
||||
|
|
|
|||
Loading…
Reference in New Issue