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:
parent
7f1b938aa5
commit
5b9596e042
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue