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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:44:13 -05:00
parent 7f1b938aa5
commit 5b9596e042
8 changed files with 213 additions and 20 deletions

View File

@ -166,6 +166,32 @@ class FirestoreConversationDataSource @Inject constructor(
awaitClose { listener.remove() } 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). */ /** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow { fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
val listener = conversationsRef(coupleId).document(conversationId) val listener = conversationsRef(coupleId).document(conversationId)
@ -216,12 +242,16 @@ class FirestoreConversationDataSource @Inject constructor(
): QuestionMessage? { ): QuestionMessage? {
val userId = getString("authorUserId") ?: return null val userId = getString("authorUserId") ?: return null
val type = getString("type") ?: "text" val type = getString("type") ?: "text"
@Suppress("UNCHECKED_CAST")
val reactions = (get("reactions") as? Map<String, String>).orEmpty()
return QuestionMessage( return QuestionMessage(
id = id, id = id,
userId = userId, userId = userId,
type = type, type = type,
mediaUrl = getString("mediaUrl") ?: "", mediaUrl = getString("mediaUrl") ?: "",
durationMs = getLong("durationMs") ?: 0L, durationMs = getLong("durationMs") ?: 0L,
reactions = reactions,
deleted = getBoolean("deleted") ?: false,
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "", text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )

View File

@ -40,6 +40,12 @@ class ConversationRepositoryImpl @Inject constructor(
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) = override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs) 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? = override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
dataSource.loadDecryptedMedia(coupleId, mediaUrl) dataSource.loadDecryptedMedia(coupleId, mediaUrl)
} }

View File

@ -10,8 +10,12 @@ data class QuestionMessage(
val mediaUrl: String = "", val mediaUrl: String = "",
/** Voice-note length in milliseconds (0 for non-voice). */ /** Voice-note length in milliseconds (0 for non-voice). */
val durationMs: Long = 0L, val durationMs: Long = 0L,
/** Emoji reactions keyed by reactor uid, e.g. {uid: "❤️"}. */
val reactions: Map<String, String> = emptyMap(),
/** True once the author has unsent (tombstoned) the message. */
val deleted: Boolean = false,
val createdAt: Long = 0L val createdAt: Long = 0L
) { ) {
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted
} }

View File

@ -14,5 +14,7 @@ interface ConversationRepository {
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) 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 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? suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
} }

View File

@ -151,6 +151,8 @@ fun ConversationScreen(
showAvatar = isLastInRun, showAvatar = isLastInRun,
showTimestamp = isLastInRun, showTimestamp = isLastInRun,
showSeen = seen, showSeen = seen,
onReact = { emoji -> viewModel.react(message.id, emoji) },
onDelete = { viewModel.deleteMessage(message.id) },
loadDecryptedMedia = viewModel::loadDecryptedMedia loadDecryptedMedia = viewModel::loadDecryptedMedia
) )
} }

View File

@ -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) { fun retryMedia(id: String) {
val data = retryStore[id] ?: return val data = retryStore[id] ?: return
removePending(id) removePending(id)

View File

@ -10,8 +10,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close 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.Image
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause 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.PhotoCamera
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator 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.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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 * 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. * are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
*/ */
val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥")
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatMessageRow( fun ChatMessageRow(
message: QuestionMessage, message: QuestionMessage,
@ -91,6 +102,10 @@ fun ChatMessageRow(
showAvatar: Boolean, showAvatar: Boolean,
showTimestamp: Boolean, showTimestamp: Boolean,
showSeen: Boolean = false, showSeen: Boolean = false,
canReact: Boolean = true,
onReact: (String) -> Unit = {},
onReactBlocked: () -> Unit = {},
onDelete: () -> Unit = {},
loadDecryptedMedia: suspend (String) -> ByteArray? loadDecryptedMedia: suspend (String) -> ByteArray?
) { ) {
val bubbleShape = if (isCurrentUser) { val bubbleShape = if (isCurrentUser) {
@ -98,6 +113,8 @@ fun ChatMessageRow(
} else { } else {
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) 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()) { Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
@ -110,26 +127,57 @@ fun ChatMessageRow(
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
when { Box(
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) modifier = if (message.deleted) Modifier
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true })
else -> Surface( ) {
shape = bubbleShape, when {
color = if (isCurrentUser) MaterialTheme.colorScheme.primary message.deleted -> DeletedBubble(bubbleShape, isCurrentUser)
else MaterialTheme.colorScheme.surfaceVariant, message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
modifier = Modifier.widthIn(max = 264.dp) message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
) { else -> Surface(
Text( shape = bubbleShape,
text = message.text, color = if (isCurrentUser) MaterialTheme.colorScheme.primary
style = MaterialTheme.typography.bodyMedium, else MaterialTheme.colorScheme.surfaceVariant,
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary modifier = Modifier.widthIn(max = 264.dp)
else MaterialTheme.colorScheme.onSurfaceVariant, ) {
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.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) { if (showTimestamp) {
val time = formatClockTime(message.createdAt) val time = formatClockTime(message.createdAt)
Text( 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<String>, 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. */ /** A centered date pill shown between messages from different days. */
@Composable @Composable
fun ChatDaySeparator(epochMillis: Long) { fun ChatDaySeparator(epochMillis: Long) {

View File

@ -433,7 +433,7 @@ service cloud.firestore {
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid && 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.get('type', 'text') in ['image', 'voice']
&& request.resource.data.mediaUrl is string && request.resource.data.mediaUrl is string
@ -442,7 +442,15 @@ service cloud.firestore {
(request.resource.data.get('type', 'text') == 'text' (request.resource.data.get('type', 'text') == 'text'
&& isCiphertext(request.resource.data.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;
} }
} }