From 5b9596e042bcfb1561e9a829ad9e76de0bab0cd5 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 18:44:13 -0500 Subject: [PATCH] feat(chat): message reactions + delete (unsend) via long-press menu Long-press a message for a reaction bar (heart/laugh/thumb/wow/sad/fire), Copy (text), and Delete (author). Reactions stored as a reactions:{uid:emoji} map; delete sets a 'deleted' tombstone ('This message was deleted') and updates the inbox preview if it was last. Rules: any member may change only reactions; author may set only deleted. Live verification pending the Phase B rules deploy. Co-Authored-By: Claude Opus 4.8 --- .../remote/FirestoreConversationDataSource.kt | 30 ++++ .../repository/ConversationRepositoryImpl.kt | 6 + .../closer/domain/model/QuestionMessage.kt | 8 +- .../repository/ConversationRepository.kt | 2 + .../closer/ui/messages/ConversationScreen.kt | 2 + .../ui/messages/ConversationViewModel.kt | 18 ++ .../ui/messages/components/ChatComponents.kt | 155 ++++++++++++++++-- firestore.rules | 12 +- 8 files changed, 213 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt index 008be8c3..f1b14f10 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -166,6 +166,32 @@ class FirestoreConversationDataSource @Inject constructor( awaitClose { listener.remove() } } + /** Toggles the caller's emoji reaction on a message (pass null to remove). Any couple member may react. */ + suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) { + val ref = messagesRef(coupleId, conversationId).document(messageId) + if (emoji == null) { + ref.update("reactions.$userId", FieldValue.delete()).voidAwait() + } else { + ref.update(mapOf("reactions.$userId" to emoji)).voidAwait() + } + } + + /** Unsend: tombstones a message (author-only) and reflects it in the inbox preview if it was last. */ + suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) { + messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).voidAwait() + val latest = runCatching { + messagesRef(coupleId, conversationId) + .orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await() + }.getOrNull() + if (latest?.documents?.firstOrNull()?.id == messageId) { + val aead = encryptionManager.aeadFor(coupleId) ?: return + conversationsRef(coupleId).document(conversationId).set( + mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)), + SetOptions.merge() + ).voidAwait() + } + } + /** Emits the partner's most recent read timestamp for this conversation (0 if never read). */ fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow = callbackFlow { val listener = conversationsRef(coupleId).document(conversationId) @@ -216,12 +242,16 @@ class FirestoreConversationDataSource @Inject constructor( ): QuestionMessage? { val userId = getString("authorUserId") ?: return null val type = getString("type") ?: "text" + @Suppress("UNCHECKED_CAST") + val reactions = (get("reactions") as? Map).orEmpty() return QuestionMessage( id = id, userId = userId, type = type, mediaUrl = getString("mediaUrl") ?: "", durationMs = getLong("durationMs") ?: 0L, + reactions = reactions, + deleted = getBoolean("deleted") ?: false, text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "", createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L ) diff --git a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt index 54cb161a..97244047 100644 --- a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -40,6 +40,12 @@ class ConversationRepositoryImpl @Inject constructor( override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) = dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs) + override suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) = + dataSource.setReaction(coupleId, conversationId, messageId, userId, emoji) + + override suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) = + dataSource.deleteMessage(coupleId, conversationId, messageId) + override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? = dataSource.loadDecryptedMedia(coupleId, mediaUrl) } diff --git a/app/src/main/java/app/closer/domain/model/QuestionMessage.kt b/app/src/main/java/app/closer/domain/model/QuestionMessage.kt index e0967628..49e5cbce 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionMessage.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionMessage.kt @@ -10,8 +10,12 @@ data class QuestionMessage( val mediaUrl: String = "", /** Voice-note length in milliseconds (0 for non-voice). */ val durationMs: Long = 0L, + /** Emoji reactions keyed by reactor uid, e.g. {uid: "❤️"}. */ + val reactions: Map = emptyMap(), + /** True once the author has unsent (tombstoned) the message. */ + val deleted: Boolean = false, val createdAt: Long = 0L ) { - val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() - val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() + val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted + val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted } diff --git a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt index 870b9899..7e39f26f 100644 --- a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -14,5 +14,7 @@ interface ConversationRepository { suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) + suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) + suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? } diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index 629c181d..ec2cd1b0 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -151,6 +151,8 @@ fun ConversationScreen( showAvatar = isLastInRun, showTimestamp = isLastInRun, showSeen = seen, + onReact = { emoji -> viewModel.react(message.id, emoji) }, + onDelete = { viewModel.deleteMessage(message.id) }, loadDecryptedMedia = viewModel::loadDecryptedMedia ) } diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index 16f178f3..e31799ac 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -161,6 +161,24 @@ class ConversationViewModel @Inject constructor( } } + /** Toggle the caller's emoji reaction on a message (tap the same emoji again to remove). */ + fun react(messageId: String, emoji: String) { + if (currentUserId.isEmpty()) return + val current = _uiState.value.messages.firstOrNull { it.id == messageId }?.reactions?.get(currentUserId) + val next = if (current == emoji) null else emoji + viewModelScope.launch { + runCatching { repository.setReaction(coupleId, conversationId, messageId, currentUserId, next) } + .onFailure { _events.tryEmit("Couldn't add reaction.") } + } + } + + fun deleteMessage(messageId: String) { + viewModelScope.launch { + runCatching { repository.deleteMessage(coupleId, conversationId, messageId) } + .onFailure { _events.tryEmit("Couldn't delete message.") } + } + } + fun retryMedia(id: String) { val data = retryStore[id] ?: return removePending(id) diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 51aa35ac..8b11e34a 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -10,8 +10,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,6 +32,8 @@ 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.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Pause @@ -37,6 +41,8 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -59,7 +65,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -83,6 +91,9 @@ import java.nio.ByteBuffer * One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages * are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages. */ +val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥") + +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatMessageRow( message: QuestionMessage, @@ -91,6 +102,10 @@ fun ChatMessageRow( showAvatar: Boolean, showTimestamp: Boolean, showSeen: Boolean = false, + canReact: Boolean = true, + onReact: (String) -> Unit = {}, + onReactBlocked: () -> Unit = {}, + onDelete: () -> Unit = {}, loadDecryptedMedia: suspend (String) -> ByteArray? ) { val bubbleShape = if (isCurrentUser) { @@ -98,6 +113,8 @@ fun ChatMessageRow( } else { RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) } + var menuOpen by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -110,26 +127,57 @@ fun ChatMessageRow( Spacer(modifier = Modifier.width(6.dp)) } - when { - message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) - message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) - else -> Surface( - shape = bubbleShape, - color = if (isCurrentUser) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.widthIn(max = 264.dp) - ) { - Text( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) - ) + Box( + modifier = if (message.deleted) Modifier + else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true }) + ) { + when { + message.deleted -> DeletedBubble(bubbleShape, isCurrentUser) + message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) + message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) + else -> Surface( + shape = bubbleShape, + color = if (isCurrentUser) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.widthIn(max = 264.dp) + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) + ) + } } + + MessageActionMenu( + expanded = menuOpen, + onDismiss = { menuOpen = false }, + canCopy = message.type == "text", + canDelete = isCurrentUser, + onReact = { emoji -> + menuOpen = false + if (canReact) onReact(emoji) else onReactBlocked() + }, + onCopy = { + menuOpen = false + clipboard.setText(AnnotatedString(message.text)) + }, + onDelete = { menuOpen = false; onDelete() } + ) } } + if (message.reactions.isNotEmpty() && !message.deleted) { + ReactionChip( + emojis = message.reactions.values.toList(), + modifier = Modifier + .align(if (isCurrentUser) Alignment.End else Alignment.Start) + .padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp) + ) + } + if (showTimestamp) { val time = formatClockTime(message.createdAt) Text( @@ -144,6 +192,81 @@ fun ChatMessageRow( } } +@Composable +private fun DeletedBubble(shape: Shape, isCurrentUser: Boolean) { + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.widthIn(max = 264.dp) + ) { + Text( + text = "This message was deleted", + style = MaterialTheme.typography.bodyMedium, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) + ) + } +} + +@Composable +private fun ReactionChip(emojis: List, modifier: Modifier = Modifier) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier + ) { + Text( + text = emojis.distinct().joinToString("") + if (emojis.size > 1) " ${emojis.size}" else "", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } +} + +@Composable +private fun MessageActionMenu( + expanded: Boolean, + onDismiss: () -> Unit, + canCopy: Boolean, + canDelete: Boolean, + onReact: (String) -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + REACTION_EMOJIS.forEach { emoji -> + Text( + text = emoji, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .clip(CircleShape) + .clickable { onReact(emoji) } + .padding(6.dp) + ) + } + } + if (canCopy) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = onCopy, + leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) } + ) + } + if (canDelete) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = onDelete, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) } + ) + } + } +} + /** A centered date pill shown between messages from different days. */ @Composable fun ChatDaySeparator(epochMillis: Long) { diff --git a/firestore.rules b/firestore.rules index 1cf03f04..0c6f41af 100644 --- a/firestore.rules +++ b/firestore.rules @@ -433,7 +433,7 @@ service cloud.firestore { allow create: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && request.resource.data.authorUserId == request.auth.uid - && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs']) + && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted']) && ( (request.resource.data.get('type', 'text') in ['image', 'voice'] && request.resource.data.mediaUrl is string @@ -442,7 +442,15 @@ service cloud.firestore { (request.resource.data.get('type', 'text') == 'text' && isCiphertext(request.resource.data.text)) ); - allow update, delete: if false; + // Reactions: any couple member may change ONLY the reactions map. + // Unsend: only the author may set the `deleted` tombstone. + allow update: if isCouplesMember(coupleId) && ( + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions']) + || + (resource.data.authorUserId == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted'])) + ); + allow delete: if false; } }