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() }
}
/** 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<Long> = 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<String, String>).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
)

View File

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

View File

@ -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<String, String> = 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
}

View File

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

View File

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

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

View File

@ -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<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. */
@Composable
fun ChatDaySeparator(epochMillis: Long) {

View File

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